FN-7109: notify operators when CLI agents await input
Notify external providers when terminal-backed CLI agents pause for tool permission or user input. - Add a dedicated cli-agent-awaiting-input notification event to settings, schema defaults, dashboard options, and provider tests. - Dispatch CLI-agent waiting-on-input notifications from the in-process runtime with task context and prompt-scoped dedupe keys. - Document the operator behavior and add a published package changeset for the new notification surface. Files changed: .changeset/fn-7109-cli-agent-notification.md | 7 ++ docs/agents.md | 4 + docs/settings-reference.md | 4 +- .../cli-agent-permission-notifications.md | 13 +++ .../core/src/__tests__/global-settings.test.ts | 1 + packages/core/src/settings-schema.ts | 1 + packages/core/src/types.ts | 6 ++ .../SettingsModal.remote-notifications.test.tsx | 1 + .../components/__tests__/settings-mobile.test.tsx | 2 +- .../settings/sections/NotificationsSection.tsx | 3 + .../src/__tests__/in-process-runtime.test.ts | 56 ++++++++++- packages/engine/src/__tests__/notifier.test.ts | 6 +- .../engine/src/__tests__/ntfy-provider.test.ts | 4 + .../engine/src/__tests__/webhook-provider.test.ts | 2 + .../engine/src/cli-agent/__tests__/runtime.test.ts | 39 +++++++- .../__tests__/notification-service.test.ts | 56 ++++++++++- .../src/notification/notification-service.ts | 9 +- packages/engine/src/notification/ntfy-provider.ts | 7 ++ .../engine/src/notification/webhook-provider.ts | 2 + packages/engine/src/notifier.ts | 1 + packages/engine/src/runtimes/in-process-runtime.ts | 111 +++++++++++++++++++++ 21 files changed, 326 insertions(+), 9 deletions(-) Fusion-Task-Id: FN-7109 Fusion-Task-Lineage: 636c1875-92a2-46a7-847e-1f4955c0822f Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7109-cli-agent-notification.md
Normal file
7
.changeset/fn-7109-cli-agent-notification.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Add external notifications for CLI agent tool-permission prompts.
|
||||||
|
category: feature
|
||||||
|
dev: Adds cli-agent-awaiting-input notification delivery from CLI waiting-on-input telemetry through ntfy/webhook providers.
|
||||||
@@ -214,6 +214,10 @@ Separation of concerns:
|
|||||||
- `permissionPolicy` determines how sensitive runtime actions are gated (`allow`, `block`, `require-approval`) once the capability path is in play.
|
- `permissionPolicy` determines how sensitive runtime actions are gated (`allow`, `block`, `require-approval`) once the capability path is in play.
|
||||||
- Dashboard persona presets (`packages/dashboard/app/components/agent-presets/`) are UI templates for identity/behavior and are **not** the source of truth for permission-policy enforcement.
|
- Dashboard persona presets (`packages/dashboard/app/components/agent-presets/`) are UI templates for identity/behavior and are **not** the source of truth for permission-policy enforcement.
|
||||||
|
|
||||||
|
### CLI agent permission prompts and notifications
|
||||||
|
|
||||||
|
CLI-agent adapters keep their own autonomy posture and tool-permission handling separate from permanent-agent `permissionPolicy`. When an adapter reports a permission/input prompt (`PermissionRequest`, `Notification`, or a conservative approval-prompt heuristic), the CLI session moves to `waitingOnInput`; the dashboard shows the session banner, and Fusion dispatches the `cli-agent-awaiting-input` notification event through enabled ntfy/webhook providers. Repeated waiting events for the same CLI session are de-duplicated before provider delivery, while the in-app banner continues to reflect the live session state.
|
||||||
|
|
||||||
### System-Managed Fields (Not User-Editable)
|
### System-Managed Fields (Not User-Editable)
|
||||||
|
|
||||||
These fields are managed by the engine and cannot be directly edited:
|
These fields are managed by the engine and cannot be directly edited:
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
|
|||||||
| `ntfyTopic` | `string` | `undefined` | ntfy topic name. |
|
| `ntfyTopic` | `string` | `undefined` | ntfy topic name. |
|
||||||
| `ntfyBaseUrl` | `string` | `undefined` | Optional custom ntfy server base URL (must use `http://` or `https://`). If blank/unset, Fusion uses `https://ntfy.sh` for both runtime and test notifications. |
|
| `ntfyBaseUrl` | `string` | `undefined` | Optional custom ntfy server base URL (must use `http://` or `https://`). If blank/unset, Fusion uses `https://ntfy.sh` for both runtime and test notifications. |
|
||||||
| `ntfyAccessToken` | `string` | `undefined` | Optional ntfy access token. When set, Fusion sends `Authorization: Bearer <token>` with ntfy publish requests, including Settings → Notifications test sends. Leave blank/unset to publish without authentication. |
|
| `ntfyAccessToken` | `string` | `undefined` | Optional ntfy access token. When set, Fusion sends `Authorization: Bearer <token>` with ntfy publish requests, including Settings → Notifications test sends. Leave blank/unset to publish without authentication. |
|
||||||
| `ntfyEvents` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "board-stall-unrecovered" \| "fallback-used" \| "task-created" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent" \| "message:room" \| "oauth-token-expired" \| "token-budget" \| "workflow-notify")[]` | `["in-review","merged","failed","awaiting-approval","awaiting-user-review","planning-awaiting-input","gridlock","board-stall-unrecovered","fallback-used","memory-dreams-processed","message:agent-to-user","message:agent-to-agent","message:room","oauth-token-expired","token-budget"]` | Event types that trigger ntfy notifications. `planning-awaiting-input` fires when planning mode is waiting on user input. `gridlock` fires when all schedulable todo tasks are blocked; delivery is cooldown-throttled (first alert immediately, then suppressed for 15 minutes until gridlock resolves). `board-stall-unrecovered` fires only after a board-stall auto-recovery sweep runs and a follow-up verification tick still sees zero progress. `fallback-used` fires when Fusion recovers from a retryable model failure by switching to a configured fallback model. `task-created` fires when an agent creates a new task (requires `sourceAgentId`) and is opt-in/off by default. `memory-dreams-processed` fires when manual dream processing writes a new `DREAMS.md` entry (project and/or agent); disable it via ntfy/webhook event filters if you want to opt out. `message:agent-to-user` fires when an agent sends a direct message to the user. `message:agent-to-agent` fires when an agent sends a message to another agent (including replies). `message:room` fires when an agent posts an assistant reply in a chat room. `oauth-token-expired` fires when a provider OAuth credential reaches its expiry and still needs re-authentication after any automatic refresh path has been tried; Fusion also throttles that notification and the matching startup expiry warning to at most once per provider every 12 hours, and the throttle persists across server restarts. `token-budget` fires when a task crosses token soft/hard caps. `workflow-notify` is emitted by workflow `notify` nodes and is opt-in/off by default; add it to `ntfyEvents` or a provider `events` list to deliver workflow-authored notifications. If you use a custom `ntfyEvents` list, these message events must be present (or `ntfyEvents` must be unset so defaults apply) for the corresponding notifications to send. |
|
| `ntfyEvents` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "cli-agent-awaiting-input" \| "gridlock" \| "board-stall-unrecovered" \| "fallback-used" \| "task-created" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent" \| "message:room" \| "oauth-token-expired" \| "token-budget" \| "workflow-notify")[]` | `["in-review","merged","failed","awaiting-approval","awaiting-user-review","planning-awaiting-input","cli-agent-awaiting-input","gridlock","board-stall-unrecovered","fallback-used","memory-dreams-processed","message:agent-to-user","message:agent-to-agent","message:room","oauth-token-expired","token-budget"]` | Event types that trigger ntfy notifications. `planning-awaiting-input` fires when planning mode is waiting on user input. `cli-agent-awaiting-input` fires when a CLI agent session is waiting on terminal input, including tool-permission prompts; these notifications are de-duplicated per CLI session and include task/session metadata for deep links. `gridlock` fires when all schedulable todo tasks are blocked; delivery is cooldown-throttled (first alert immediately, then suppressed for 15 minutes until gridlock resolves). `board-stall-unrecovered` fires only after a board-stall auto-recovery sweep runs and a follow-up verification tick still sees zero progress. `fallback-used` fires when Fusion recovers from a retryable model failure by switching to a configured fallback model. `task-created` fires when an agent creates a new task (requires `sourceAgentId`) and is opt-in/off by default. `memory-dreams-processed` fires when manual dream processing writes a new `DREAMS.md` entry (project and/or agent); disable it via ntfy/webhook event filters if you want to opt out. `message:agent-to-user` fires when an agent sends a direct message to the user. `message:agent-to-agent` fires when an agent sends a message to another agent (including replies). `message:room` fires when an agent posts an assistant reply in a chat room. `oauth-token-expired` fires when a provider OAuth credential reaches its expiry and still needs re-authentication after any automatic refresh path has been tried; Fusion also throttles that notification and the matching startup expiry warning to at most once per provider every 12 hours, and the throttle persists across server restarts. `token-budget` fires when a task crosses token soft/hard caps. `workflow-notify` is emitted by workflow `notify` nodes and is opt-in/off by default; add it to `ntfyEvents` or a provider `events` list to deliver workflow-authored notifications. If you use a custom `ntfyEvents` list, these message and CLI-agent events must be present (or `ntfyEvents` must be unset so defaults apply) for the corresponding notifications to send. |
|
||||||
| `ntfyDashboardHost` | `string` | `undefined` | Dashboard host used to build deep links in notifications. |
|
| `ntfyDashboardHost` | `string` | `undefined` | Dashboard host used to build deep links in notifications. |
|
||||||
| `taskTokenBudget` | `{ soft?: number; hard?: number; perSize?: { S?: { soft?: number; hard?: number }; M?: { soft?: number; hard?: number }; L?: { soft?: number; hard?: number } } }` | `undefined` | Global fallback per-task token budget policy. Project `taskTokenBudget` overrides this. |
|
| `taskTokenBudget` | `{ soft?: number; hard?: number; perSize?: { S?: { soft?: number; hard?: number }; M?: { soft?: number; hard?: number }; L?: { soft?: number; hard?: number } } }` | `undefined` | Global fallback per-task token budget policy. Project `taskTokenBudget` overrides this. |
|
||||||
| `webhookEnabled` | `boolean` | `false` | Enable webhook notifications for task lifecycle events. Part of the legacy flat settings; prefer `notificationProviders` for new setups. |
|
| `webhookEnabled` | `boolean` | `false` | Enable webhook notifications for task lifecycle events. Part of the legacy flat settings; prefer `notificationProviders` for new setups. |
|
||||||
@@ -212,7 +212,7 @@ When `id` is `"ntfy"` in `notificationProviders`, the provider `config` supports
|
|||||||
| `topic` | `string` | _required_ | ntfy topic name (1–64 chars, alphanumeric + `-_`). |
|
| `topic` | `string` | _required_ | ntfy topic name (1–64 chars, alphanumeric + `-_`). |
|
||||||
| `ntfyBaseUrl` | `string` | `"https://ntfy.sh"` | Optional custom ntfy server URL. |
|
| `ntfyBaseUrl` | `string` | `"https://ntfy.sh"` | Optional custom ntfy server URL. |
|
||||||
| `ntfyAccessToken` | `string` | `undefined` | Optional access token. When set, provider sends `Authorization: Bearer <token>` on ntfy publishes. |
|
| `ntfyAccessToken` | `string` | `undefined` | Optional access token. When set, provider sends `Authorization: Bearer <token>` on ntfy publishes. |
|
||||||
| `events` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "board-stall-unrecovered" \| "fallback-used" \| "task-created" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent" \| "message:room" \| "oauth-token-expired" \| "workflow-notify")[]` | `DEFAULT_NTFY_EVENTS` | Event filter list used by the provider. For `gridlock`, enabled events are still cooldown-throttled at runtime (15-minute suppression window, reset on full resolution). `board-stall-unrecovered` is emitted when board-stall verification fails after an attempted auto-recovery sweep. `task-created` is available as an opt-in event and only fires for agent-created tasks (`sourceAgentId` required). `memory-dreams-processed` is emitted when manual dream processing appends a new project/agent `DREAMS.md` entry. `message:agent-to-user`/`message:agent-to-agent` are emitted for mailbox messages and deep-link to the specific message when `dashboardHost` is configured. `message:room` is emitted for assistant replies in chat rooms and deep-links to the room when `dashboardHost` is configured. `oauth-token-expired` is emitted when a provider OAuth credential has expired and cannot be automatically refreshed; Fusion suppresses repeat delivery for the same provider for 12 hours even across server restarts, and applies the same persisted window to the startup expiry warning log. `workflow-notify` is emitted by workflow `notify` nodes and remains opt-in/off by default because it is not included in `DEFAULT_NTFY_EVENTS`. |
|
| `events` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "cli-agent-awaiting-input" \| "gridlock" \| "board-stall-unrecovered" \| "fallback-used" \| "task-created" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent" \| "message:room" \| "oauth-token-expired" \| "workflow-notify")[]` | `DEFAULT_NTFY_EVENTS` | Event filter list used by the provider. `cli-agent-awaiting-input` is emitted when a CLI agent session waits on terminal input or a tool-permission prompt. For `gridlock`, enabled events are still cooldown-throttled at runtime (15-minute suppression window, reset on full resolution). `board-stall-unrecovered` is emitted when board-stall verification fails after an attempted auto-recovery sweep. `task-created` is available as an opt-in event and only fires for agent-created tasks (`sourceAgentId` required). `memory-dreams-processed` is emitted when manual dream processing appends a new project/agent `DREAMS.md` entry. `message:agent-to-user`/`message:agent-to-agent` are emitted for mailbox messages and deep-link to the specific message when `dashboardHost` is configured. `message:room` is emitted for assistant replies in chat rooms and deep-links to the room when `dashboardHost` is configured. `oauth-token-expired` is emitted when a provider OAuth credential has expired and cannot be automatically refreshed; Fusion suppresses repeat delivery for the same provider for 12 hours even across server restarts, and applies the same persisted window to the startup expiry warning log. `workflow-notify` is emitted by workflow `notify` nodes and remains opt-in/off by default because it is not included in `DEFAULT_NTFY_EVENTS`. |
|
||||||
| `dashboardHost` | `string` | `undefined` | Dashboard host for deep links in notifications. |
|
| `dashboardHost` | `string` | `undefined` | Dashboard host for deep links in notifications. |
|
||||||
|
|
||||||
Disable daily update checks globally:
|
Disable daily update checks globally:
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
category: integration-issues
|
||||||
|
module: engine/cli-agent
|
||||||
|
tags: [cli-agent, notifications, permissions, ntfy, webhook]
|
||||||
|
problem_type: runtime-wiring
|
||||||
|
applies_when: CLI-agent sessions enter waitingOnInput but external notification providers do not receive permission/input alerts
|
||||||
|
---
|
||||||
|
|
||||||
|
# CLI-agent permission prompts must wire `TelemetryHub.onNotification`
|
||||||
|
|
||||||
|
CLI adapters normalize tool-permission and terminal-input prompts to `TelemetryHub.ingest(..., { kind: "waitingOnInput" })`. The hub updates session state for the in-app banner and invokes the optional `onNotification` callback for external delivery.
|
||||||
|
|
||||||
|
If a runtime constructs `createCliAgentRuntime(...)` without `onNotification`, the dashboard can still show `waiting_on_input`, but ntfy/webhook providers never receive the alert. Wire runtime construction to dispatch `cli-agent-awaiting-input` through `getActiveNotificationService()?.dispatch(...)`, include task/session metadata, and provide a `notificationDedupeKey` so duplicate waiting telemetry does not spam providers.
|
||||||
@@ -375,6 +375,7 @@ describe("GlobalSettingsStore", () => {
|
|||||||
"awaiting-approval",
|
"awaiting-approval",
|
||||||
"awaiting-user-review",
|
"awaiting-user-review",
|
||||||
"planning-awaiting-input",
|
"planning-awaiting-input",
|
||||||
|
"cli-agent-awaiting-input",
|
||||||
"message:agent-to-user",
|
"message:agent-to-user",
|
||||||
"message:agent-to-agent",
|
"message:agent-to-agent",
|
||||||
"message:room",
|
"message:room",
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
|
|||||||
"awaiting-approval",
|
"awaiting-approval",
|
||||||
"awaiting-user-review",
|
"awaiting-user-review",
|
||||||
"planning-awaiting-input",
|
"planning-awaiting-input",
|
||||||
|
"cli-agent-awaiting-input",
|
||||||
"message:agent-to-user",
|
"message:agent-to-user",
|
||||||
"message:agent-to-agent",
|
"message:agent-to-agent",
|
||||||
"message:room",
|
"message:room",
|
||||||
|
|||||||
@@ -693,6 +693,7 @@ export type NtfyNotificationEvent =
|
|||||||
| "awaiting-approval"
|
| "awaiting-approval"
|
||||||
| "awaiting-user-review"
|
| "awaiting-user-review"
|
||||||
| "planning-awaiting-input"
|
| "planning-awaiting-input"
|
||||||
|
| "cli-agent-awaiting-input"
|
||||||
| "gridlock"
|
| "gridlock"
|
||||||
| "board-stall-unrecovered"
|
| "board-stall-unrecovered"
|
||||||
| "db-corruption-detected"
|
| "db-corruption-detected"
|
||||||
@@ -714,6 +715,11 @@ export const NOTIFICATION_EVENTS = [
|
|||||||
"awaiting-approval",
|
"awaiting-approval",
|
||||||
"awaiting-user-review",
|
"awaiting-user-review",
|
||||||
"planning-awaiting-input",
|
"planning-awaiting-input",
|
||||||
|
/*
|
||||||
|
* FNXC:ToolPermissionNotifications 2026-06-27-00:00:
|
||||||
|
* CLI tool-permission requests are a distinct user-facing notification event from plan approval. Operators must be able to enable external alerts when a terminal-backed agent waits for human input.
|
||||||
|
*/
|
||||||
|
"cli-agent-awaiting-input",
|
||||||
"gridlock",
|
"gridlock",
|
||||||
"board-stall-unrecovered",
|
"board-stall-unrecovered",
|
||||||
"db-corruption-detected",
|
"db-corruption-detected",
|
||||||
|
|||||||
@@ -707,6 +707,7 @@ describe("SettingsModal", () => {
|
|||||||
"awaiting-approval",
|
"awaiting-approval",
|
||||||
"awaiting-user-review",
|
"awaiting-user-review",
|
||||||
"planning-awaiting-input",
|
"planning-awaiting-input",
|
||||||
|
"cli-agent-awaiting-input",
|
||||||
"gridlock",
|
"gridlock",
|
||||||
"fallback-used",
|
"fallback-used",
|
||||||
"memory-dreams-processed",
|
"memory-dreams-processed",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const defaultSettings = {
|
|||||||
defaultPresetBySize: {},
|
defaultPresetBySize: {},
|
||||||
ntfyEnabled: false,
|
ntfyEnabled: false,
|
||||||
ntfyTopic: undefined,
|
ntfyTopic: undefined,
|
||||||
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "fallback-used", "memory-dreams-processed", "oauth-token-expired"],
|
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "cli-agent-awaiting-input", "fallback-used", "memory-dreams-processed", "oauth-token-expired"],
|
||||||
webhookEnabled: false,
|
webhookEnabled: false,
|
||||||
webhookUrl: undefined,
|
webhookUrl: undefined,
|
||||||
webhookFormat: undefined,
|
webhookFormat: undefined,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const DEFAULT_NTFY_EVENTS: NtfyNotificationEvent[] = [
|
|||||||
"awaiting-approval",
|
"awaiting-approval",
|
||||||
"awaiting-user-review",
|
"awaiting-user-review",
|
||||||
"planning-awaiting-input",
|
"planning-awaiting-input",
|
||||||
|
"cli-agent-awaiting-input",
|
||||||
"gridlock",
|
"gridlock",
|
||||||
"fallback-used",
|
"fallback-used",
|
||||||
"memory-dreams-processed",
|
"memory-dreams-processed",
|
||||||
@@ -29,6 +30,8 @@ export const NOTIFICATION_EVENT_OPTIONS: Array<{
|
|||||||
{ event: "awaiting-approval", label: "Plan needs approval", description: "When a task specification needs manual approval before execution" },
|
{ event: "awaiting-approval", label: "Plan needs approval", description: "When a task specification needs manual approval before execution" },
|
||||||
{ event: "awaiting-user-review", label: "User review needed", description: "When an agent hands off a task for human review (high priority)" },
|
{ event: "awaiting-user-review", label: "User review needed", description: "When an agent hands off a task for human review (high priority)" },
|
||||||
{ event: "planning-awaiting-input", label: "Planning needs input", description: "When planning mode is waiting for your response to continue" },
|
{ event: "planning-awaiting-input", label: "Planning needs input", description: "When planning mode is waiting for your response to continue" },
|
||||||
|
// FNXC:ToolPermissionNotifications 2026-06-27-00:00: Settings must expose CLI-agent awaiting-input alerts separately from plan approval so operators can opt into external notifications for blocked terminal tool permissions.
|
||||||
|
{ event: "cli-agent-awaiting-input", label: "CLI agent needs input", description: "When a CLI agent is blocked on a tool permission or terminal input prompt" },
|
||||||
{ event: "gridlock", label: "Pipeline gridlocked", description: "When all schedulable todo tasks are blocked and work cannot advance" },
|
{ event: "gridlock", label: "Pipeline gridlocked", description: "When all schedulable todo tasks are blocked and work cannot advance" },
|
||||||
{ event: "fallback-used", label: "Fallback model used (recovered)", description: "When Fusion recovers from a retryable model failure by switching to a fallback model" },
|
{ event: "fallback-used", label: "Fallback model used (recovered)", description: "When Fusion recovers from a retryable model failure by switching to a fallback model" },
|
||||||
{ event: "task-created", label: "Agent created a task", description: "When an agent files a new task on the board" },
|
{ event: "task-created", label: "Agent created a task", description: "When an agent files a new task on the board" },
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { describe, it, expect, vi } from "vitest";
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { InProcessRuntime } from "../runtimes/in-process-runtime.js";
|
import type { CliSession } from "@fusion/core";
|
||||||
|
import {
|
||||||
|
buildCliAgentAwaitingInputNotificationPayload,
|
||||||
|
InProcessRuntime,
|
||||||
|
} from "../runtimes/in-process-runtime.js";
|
||||||
|
|
||||||
describe("InProcessRuntime onStart duplicate guard", () => {
|
describe("InProcessRuntime onStart duplicate guard", () => {
|
||||||
it("contains a taskAgentMap guard before creating task-worker agents", () => {
|
it("contains a taskAgentMap guard before creating task-worker agents", () => {
|
||||||
@@ -24,6 +28,56 @@ describe("InProcessRuntime onStart duplicate guard", () => {
|
|||||||
expect(source).toContain("activeMissionAutopilot.recoverMissions(activeMissionStore)");
|
expect(source).toContain("activeMissionAutopilot.recoverMissions(activeMissionStore)");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds prompt-scoped CLI input notification dedupe keys", () => {
|
||||||
|
const makeSession = (updatedAt: string): CliSession => ({
|
||||||
|
id: "cli-1",
|
||||||
|
taskId: "FN-7109",
|
||||||
|
chatSessionId: null,
|
||||||
|
purpose: "execute",
|
||||||
|
projectId: "proj-test",
|
||||||
|
adapterId: "claude-code",
|
||||||
|
agentState: "waitingOnInput",
|
||||||
|
terminationReason: null,
|
||||||
|
nativeSessionId: null,
|
||||||
|
resumeAttempts: 0,
|
||||||
|
autonomyPosture: null,
|
||||||
|
worktreePath: null,
|
||||||
|
createdAt: "2026-06-27T00:00:00.000Z",
|
||||||
|
updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = buildCliAgentAwaitingInputNotificationPayload({
|
||||||
|
projectId: "proj-test",
|
||||||
|
info: {
|
||||||
|
sessionId: "cli-1",
|
||||||
|
notification: { kind: "permission_request", toolName: "Bash", prompt: "run tests" },
|
||||||
|
},
|
||||||
|
session: makeSession("2026-06-27T00:01:00.000Z"),
|
||||||
|
task: undefined,
|
||||||
|
});
|
||||||
|
const duplicate = buildCliAgentAwaitingInputNotificationPayload({
|
||||||
|
projectId: "proj-test",
|
||||||
|
info: {
|
||||||
|
sessionId: "cli-1",
|
||||||
|
notification: { prompt: "run tests", toolName: "Bash", kind: "permission_request" },
|
||||||
|
},
|
||||||
|
session: makeSession("2026-06-27T00:01:00.000Z"),
|
||||||
|
task: undefined,
|
||||||
|
});
|
||||||
|
const nextPromptSameSession = buildCliAgentAwaitingInputNotificationPayload({
|
||||||
|
projectId: "proj-test",
|
||||||
|
info: {
|
||||||
|
sessionId: "cli-1",
|
||||||
|
notification: { kind: "permission_request", toolName: "Bash", prompt: "run build" },
|
||||||
|
},
|
||||||
|
session: makeSession("2026-06-27T00:02:00.000Z"),
|
||||||
|
task: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(first.metadata?.notificationDedupeKey).toBe(duplicate.metadata?.notificationDedupeKey);
|
||||||
|
expect(nextPromptSameSession.metadata?.notificationDedupeKey).not.toBe(first.metadata?.notificationDedupeKey);
|
||||||
|
});
|
||||||
|
|
||||||
it("forwards task:deleted events with and without githubIssueAction metadata", () => {
|
it("forwards task:deleted events with and without githubIssueAction metadata", () => {
|
||||||
const runtime = new InProcessRuntime(
|
const runtime = new InProcessRuntime(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ describe("Ntfy notifier helpers", () => {
|
|||||||
it("includes mailbox message events in default events", () => {
|
it("includes mailbox message events in default events", () => {
|
||||||
expect(DEFAULT_NTFY_EVENTS).toContain("planning-awaiting-input");
|
expect(DEFAULT_NTFY_EVENTS).toContain("planning-awaiting-input");
|
||||||
expect(resolveNtfyEvents(undefined)).toContain("planning-awaiting-input");
|
expect(resolveNtfyEvents(undefined)).toContain("planning-awaiting-input");
|
||||||
|
expect(DEFAULT_NTFY_EVENTS).toContain("cli-agent-awaiting-input");
|
||||||
|
expect(resolveNtfyEvents(undefined)).toContain("cli-agent-awaiting-input");
|
||||||
expect(DEFAULT_NTFY_EVENTS).toContain("gridlock");
|
expect(DEFAULT_NTFY_EVENTS).toContain("gridlock");
|
||||||
expect(DEFAULT_NTFY_EVENTS).toContain("fallback-used");
|
expect(DEFAULT_NTFY_EVENTS).toContain("fallback-used");
|
||||||
expect(DEFAULT_NTFY_EVENTS).toContain("message:agent-to-user");
|
expect(DEFAULT_NTFY_EVENTS).toContain("message:agent-to-user");
|
||||||
@@ -26,9 +28,11 @@ describe("Ntfy notifier helpers", () => {
|
|||||||
expect(DEFAULT_NTFY_EVENTS).toContain("message:room");
|
expect(DEFAULT_NTFY_EVENTS).toContain("message:room");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("checks planning-awaiting-input event enablement", () => {
|
it("checks awaiting-input event enablement", () => {
|
||||||
expect(isNtfyEventEnabled(["planning-awaiting-input"], "planning-awaiting-input")).toBe(true);
|
expect(isNtfyEventEnabled(["planning-awaiting-input"], "planning-awaiting-input")).toBe(true);
|
||||||
expect(isNtfyEventEnabled(["failed"], "planning-awaiting-input")).toBe(false);
|
expect(isNtfyEventEnabled(["failed"], "planning-awaiting-input")).toBe(false);
|
||||||
|
expect(isNtfyEventEnabled(["cli-agent-awaiting-input"], "cli-agent-awaiting-input")).toBe(true);
|
||||||
|
expect(isNtfyEventEnabled(["failed"], "cli-agent-awaiting-input")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports task-created enablement while keeping it default-off", () => {
|
it("supports task-created enablement while keeping it default-off", () => {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ describe("NtfyNotificationProvider", () => {
|
|||||||
"awaiting-approval",
|
"awaiting-approval",
|
||||||
"awaiting-user-review",
|
"awaiting-user-review",
|
||||||
"planning-awaiting-input",
|
"planning-awaiting-input",
|
||||||
|
"cli-agent-awaiting-input",
|
||||||
"fallback-used",
|
"fallback-used",
|
||||||
"message:agent-to-user",
|
"message:agent-to-user",
|
||||||
"message:agent-to-agent",
|
"message:agent-to-agent",
|
||||||
@@ -57,6 +58,7 @@ describe("NtfyNotificationProvider", () => {
|
|||||||
["awaiting-approval", "Plan needs approval for FN-1", "needs your approval", "high"],
|
["awaiting-approval", "Plan needs approval for FN-1", "needs your approval", "high"],
|
||||||
["awaiting-user-review", "User review needed for FN-1", "needs human review", "high"],
|
["awaiting-user-review", "User review needed for FN-1", "needs human review", "high"],
|
||||||
["planning-awaiting-input", "Planning input needed for FN-1", "awaiting your input", "high"],
|
["planning-awaiting-input", "Planning input needed for FN-1", "awaiting your input", "high"],
|
||||||
|
["cli-agent-awaiting-input", "CLI agent input needed for FN-1", "permission_request", "high"],
|
||||||
["fallback-used", "Fallback model used for FN-1", "switched from", "high"],
|
["fallback-used", "Fallback model used for FN-1", "switched from", "high"],
|
||||||
["task-created", "New task FN-1 created by agent", "Triage Bot created \"T\"", "default"],
|
["task-created", "New task FN-1 created by agent", "Triage Bot created \"T\"", "default"],
|
||||||
["message:agent-to-user", "New message from Triage Bot", "Triage Bot → you: preview text", "high"],
|
["message:agent-to-user", "New message from Triage Bot", "Triage Bot → you: preview text", "high"],
|
||||||
@@ -82,6 +84,7 @@ describe("NtfyNotificationProvider", () => {
|
|||||||
providerId: "openai-codex",
|
providerId: "openai-codex",
|
||||||
providerName: "OpenAI Codex",
|
providerName: "OpenAI Codex",
|
||||||
agentName: "Triage Bot",
|
agentName: "Triage Bot",
|
||||||
|
notificationKind: "permission_request",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,6 +111,7 @@ describe("NtfyNotificationProvider", () => {
|
|||||||
expect(provider.isEventSupported("awaiting-approval" as any)).toBe(true);
|
expect(provider.isEventSupported("awaiting-approval" as any)).toBe(true);
|
||||||
expect(provider.isEventSupported("awaiting-user-review" as any)).toBe(true);
|
expect(provider.isEventSupported("awaiting-user-review" as any)).toBe(true);
|
||||||
expect(provider.isEventSupported("planning-awaiting-input" as any)).toBe(true);
|
expect(provider.isEventSupported("planning-awaiting-input" as any)).toBe(true);
|
||||||
|
expect(provider.isEventSupported("cli-agent-awaiting-input" as any)).toBe(true);
|
||||||
expect(provider.isEventSupported("fallback-used" as any)).toBe(true);
|
expect(provider.isEventSupported("fallback-used" as any)).toBe(true);
|
||||||
expect(provider.isEventSupported("task-created" as any)).toBe(true);
|
expect(provider.isEventSupported("task-created" as any)).toBe(true);
|
||||||
expect(provider.isEventSupported("message:agent-to-user" as any)).toBe(true);
|
expect(provider.isEventSupported("message:agent-to-user" as any)).toBe(true);
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ describe("WebhookNotificationProvider", () => {
|
|||||||
["awaiting-approval", "needs your approval before it can proceed"],
|
["awaiting-approval", "needs your approval before it can proceed"],
|
||||||
["awaiting-user-review", "needs human review before it can proceed"],
|
["awaiting-user-review", "needs human review before it can proceed"],
|
||||||
["planning-awaiting-input", "is awaiting your input during planning"],
|
["planning-awaiting-input", "is awaiting your input during planning"],
|
||||||
|
["cli-agent-awaiting-input", "has a CLI agent waiting for permission_request"],
|
||||||
["gridlock", "Pipeline gridlocked"],
|
["gridlock", "Pipeline gridlocked"],
|
||||||
["fallback-used", "Fusion recovered by switching from"],
|
["fallback-used", "Fusion recovered by switching from"],
|
||||||
["message:agent-to-user", "From: Triage Bot → You: hello"],
|
["message:agent-to-user", "From: Triage Bot → You: hello"],
|
||||||
@@ -187,6 +188,7 @@ describe("WebhookNotificationProvider", () => {
|
|||||||
roomId: "room-1",
|
roomId: "room-1",
|
||||||
roomName: "Incident Room",
|
roomName: "Incident Room",
|
||||||
preview: "hello",
|
preview: "hello",
|
||||||
|
notificationKind: "permission_request",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
import { mkdtempSync } from "node:fs";
|
import { mkdtempSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
@@ -41,7 +41,7 @@ interface Harness {
|
|||||||
fusionDir: string;
|
fusionDir: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeHarness(): Harness {
|
function makeHarness(options: Partial<Parameters<typeof createCliAgentRuntime>[0]> = {}): Harness {
|
||||||
const tmpDir = mkdtempSync(join(tmpdir(), "fn-cli-runtime-test-"));
|
const tmpDir = mkdtempSync(join(tmpdir(), "fn-cli-runtime-test-"));
|
||||||
const fusionDir = join(tmpDir, ".fusion");
|
const fusionDir = join(tmpDir, ".fusion");
|
||||||
const db = new Database(fusionDir, { inMemory: true });
|
const db = new Database(fusionDir, { inMemory: true });
|
||||||
@@ -52,6 +52,7 @@ function makeHarness(): Harness {
|
|||||||
projectId: "proj-1",
|
projectId: "proj-1",
|
||||||
hookEndpointUrl: "http://127.0.0.1:4040/api/cli-agent/hooks",
|
hookEndpointUrl: "http://127.0.0.1:4040/api/cli-agent/hooks",
|
||||||
managerOptions: { loadPty: async () => makeMockPtyModule() },
|
managerOptions: { loadPty: async () => makeMockPtyModule() },
|
||||||
|
...options,
|
||||||
});
|
});
|
||||||
return { runtime, db, tmpDir, fusionDir };
|
return { runtime, db, tmpDir, fusionDir };
|
||||||
}
|
}
|
||||||
@@ -79,6 +80,40 @@ describe("createCliAgentRuntime", () => {
|
|||||||
expect(bundle.hookEndpointUrl).toBe("http://127.0.0.1:4040/api/cli-agent/hooks");
|
expect(bundle.hookEndpointUrl).toBe("http://127.0.0.1:4040/api/cli-agent/hooks");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forwards waitingOnInput permission notifications through the runtime hub", () => {
|
||||||
|
const onNotification = vi.fn();
|
||||||
|
h.runtime.dispose();
|
||||||
|
h.runtime = createCliAgentRuntime({
|
||||||
|
fusionDir: h.fusionDir,
|
||||||
|
db: h.db,
|
||||||
|
projectId: "proj-1",
|
||||||
|
hookEndpointUrl: "http://127.0.0.1:4040/api/cli-agent/hooks",
|
||||||
|
managerOptions: { loadPty: async () => makeMockPtyModule() },
|
||||||
|
onNotification,
|
||||||
|
});
|
||||||
|
const adapterId = BUNDLED_CLI_ADAPTERS[0].id;
|
||||||
|
const session = h.runtime.bundle.store.createSession({
|
||||||
|
adapterId,
|
||||||
|
projectId: "proj-1",
|
||||||
|
purpose: "execute",
|
||||||
|
taskId: "FN-7109",
|
||||||
|
worktreePath: "/wt/permission",
|
||||||
|
agentState: "busy",
|
||||||
|
});
|
||||||
|
|
||||||
|
h.runtime.bundle.hub.issueToken(session.id);
|
||||||
|
h.runtime.bundle.hub.ingest(session.id, {
|
||||||
|
kind: "waitingOnInput",
|
||||||
|
payload: { notification: { kind: "permission_request", toolName: "Bash" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onNotification).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onNotification).toHaveBeenCalledWith({
|
||||||
|
sessionId: session.id,
|
||||||
|
notification: { kind: "permission_request", toolName: "Bash" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("registers all bundled adapters into a per-runtime registry", () => {
|
it("registers all bundled adapters into a per-runtime registry", () => {
|
||||||
const ids = h.runtime.bundle.registry.ids().sort();
|
const ids = h.runtime.bundle.registry.ids().sort();
|
||||||
const expected = BUNDLED_CLI_ADAPTERS.map((a) => a.id).sort();
|
const expected = BUNDLED_CLI_ADAPTERS.map((a) => a.id).sort();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { NotificationProvider, Settings, Task } from "@fusion/core";
|
import type { NotificationPayload, NotificationProvider, Settings, Task } from "@fusion/core";
|
||||||
import { NotificationService } from "../notification-service.js";
|
import { NotificationService } from "../notification-service.js";
|
||||||
import { schedulerLog } from "../../logger.js";
|
import { schedulerLog } from "../../logger.js";
|
||||||
|
|
||||||
@@ -304,3 +304,57 @@ describe("NotificationService deferred failure notifications", () => {
|
|||||||
expect(sendNotification).not.toHaveBeenCalled();
|
expect(sendNotification).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("NotificationService manual dispatch dedupe", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setup(settings: Partial<Settings> = {}) {
|
||||||
|
const store = createStore(settings);
|
||||||
|
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
|
||||||
|
const provider: NotificationProvider = {
|
||||||
|
getProviderId: () => "mock",
|
||||||
|
isEventSupported: () => true,
|
||||||
|
sendNotification,
|
||||||
|
};
|
||||||
|
const service = new NotificationService(store as any);
|
||||||
|
service.registerProvider(provider);
|
||||||
|
await service.start();
|
||||||
|
return { service, sendNotification };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("suppresses duplicate CLI permission notifications using a metadata dedupe key", async () => {
|
||||||
|
const { service, sendNotification } = await setup();
|
||||||
|
const payload: NotificationPayload = {
|
||||||
|
taskId: "FN-7109",
|
||||||
|
event: "cli-agent-awaiting-input",
|
||||||
|
metadata: {
|
||||||
|
notificationDedupeKey: "cli-agent:proj-1:session-1:cli-agent-awaiting-input",
|
||||||
|
notificationKind: "permission_request",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.dispatch("cli-agent-awaiting-input", payload);
|
||||||
|
await service.dispatch("cli-agent-awaiting-input", payload);
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
expect(sendNotification).toHaveBeenCalledTimes(1);
|
||||||
|
expect(sendNotification).toHaveBeenCalledWith("cli-agent-awaiting-input", expect.objectContaining({ taskId: "FN-7109" }));
|
||||||
|
await service.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no-ops manual dispatch cleanly when notifications are disabled", async () => {
|
||||||
|
const { service, sendNotification } = await setup({ ntfyEnabled: false, ntfyTopic: undefined });
|
||||||
|
|
||||||
|
await service.dispatch("cli-agent-awaiting-input", {
|
||||||
|
taskId: "FN-7109",
|
||||||
|
event: "cli-agent-awaiting-input",
|
||||||
|
metadata: { notificationDedupeKey: "cli-agent:disabled" },
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
expect(sendNotification).not.toHaveBeenCalled();
|
||||||
|
await service.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -707,7 +707,14 @@ export class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private maybeNotify(taskId: string, eventType: NotificationEvent, payload: NotificationPayload): void {
|
private maybeNotify(taskId: string, eventType: NotificationEvent, payload: NotificationPayload): void {
|
||||||
const key = `${taskId}:${eventType}`;
|
const metadataDedupeKey = typeof payload.metadata?.notificationDedupeKey === "string"
|
||||||
|
? payload.metadata.notificationDedupeKey.trim()
|
||||||
|
: "";
|
||||||
|
/*
|
||||||
|
* FNXC:ToolPermissionNotifications 2026-06-27-00:00:
|
||||||
|
* Some notification surfaces are not task-lifecycle events. Honor a caller-provided dedupe key so CLI tool-permission prompts can suppress repeated telemetry records without suppressing unrelated future task notifications.
|
||||||
|
*/
|
||||||
|
const key = metadataDedupeKey.length > 0 ? metadataDedupeKey : `${taskId}:${eventType}`;
|
||||||
if (this.notifiedEvents.has(key)) {
|
if (this.notifiedEvents.has(key)) {
|
||||||
schedulerLog.log(`NotificationService.maybeNotify suppressed duplicate key=${key}`);
|
schedulerLog.log(`NotificationService.maybeNotify suppressed duplicate key=${key}`);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ type SupportedNtfyEvent =
|
|||||||
| "awaiting-approval"
|
| "awaiting-approval"
|
||||||
| "awaiting-user-review"
|
| "awaiting-user-review"
|
||||||
| "planning-awaiting-input"
|
| "planning-awaiting-input"
|
||||||
|
| "cli-agent-awaiting-input"
|
||||||
| "fallback-used"
|
| "fallback-used"
|
||||||
| "task-created"
|
| "task-created"
|
||||||
| "workflow-notify"
|
| "workflow-notify"
|
||||||
@@ -52,6 +53,7 @@ const SUPPORTED_EVENTS = new Set<SupportedNtfyEvent>([
|
|||||||
"awaiting-approval",
|
"awaiting-approval",
|
||||||
"awaiting-user-review",
|
"awaiting-user-review",
|
||||||
"planning-awaiting-input",
|
"planning-awaiting-input",
|
||||||
|
"cli-agent-awaiting-input",
|
||||||
"fallback-used",
|
"fallback-used",
|
||||||
"task-created",
|
"task-created",
|
||||||
"workflow-notify",
|
"workflow-notify",
|
||||||
@@ -227,6 +229,11 @@ export class NtfyNotificationProvider implements NotificationProvider {
|
|||||||
message: `Task "${identifier}" is awaiting your input during planning`,
|
message: `Task "${identifier}" is awaiting your input during planning`,
|
||||||
priority: "high",
|
priority: "high",
|
||||||
},
|
},
|
||||||
|
"cli-agent-awaiting-input": {
|
||||||
|
title: `CLI agent input needed for ${taskId}`,
|
||||||
|
message: `Task "${identifier}" has a CLI agent waiting for ${String(payload.metadata?.notificationKind ?? "human input")}`,
|
||||||
|
priority: "high",
|
||||||
|
},
|
||||||
"fallback-used": {
|
"fallback-used": {
|
||||||
title: `Fallback model used${payload.taskId ? ` for ${payload.taskId}` : ""}`,
|
title: `Fallback model used${payload.taskId ? ` for ${payload.taskId}` : ""}`,
|
||||||
message: `Fusion switched from ${String(payload.metadata?.primaryModel ?? "primary model")} to ${String(payload.metadata?.fallbackModel ?? "fallback model")} after a retryable failure (${String(payload.metadata?.triggerPoint ?? "unknown trigger")}).`,
|
message: `Fusion switched from ${String(payload.metadata?.primaryModel ?? "primary model")} to ${String(payload.metadata?.fallbackModel ?? "fallback model")} after a retryable failure (${String(payload.metadata?.triggerPoint ?? "unknown trigger")}).`,
|
||||||
|
|||||||
@@ -170,6 +170,8 @@ export class WebhookNotificationProvider implements NotificationProvider {
|
|||||||
return `Task "${identifier}" needs human review before it can proceed`;
|
return `Task "${identifier}" needs human review before it can proceed`;
|
||||||
case "planning-awaiting-input":
|
case "planning-awaiting-input":
|
||||||
return `Task "${identifier}" is awaiting your input during planning`;
|
return `Task "${identifier}" is awaiting your input during planning`;
|
||||||
|
case "cli-agent-awaiting-input":
|
||||||
|
return `Task "${identifier}" has a CLI agent waiting for ${String(payload.metadata?.notificationKind ?? "human input")}`;
|
||||||
case "gridlock":
|
case "gridlock":
|
||||||
return "Pipeline gridlocked";
|
return "Pipeline gridlocked";
|
||||||
case "fallback-used":
|
case "fallback-used":
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const DEFAULT_NTFY_EVENTS: readonly NtfyNotificationEvent[] = [
|
|||||||
"awaiting-approval",
|
"awaiting-approval",
|
||||||
"awaiting-user-review",
|
"awaiting-user-review",
|
||||||
"planning-awaiting-input",
|
"planning-awaiting-input",
|
||||||
|
"cli-agent-awaiting-input",
|
||||||
"gridlock",
|
"gridlock",
|
||||||
"board-stall-unrecovered",
|
"board-stall-unrecovered",
|
||||||
"db-corruption-detected",
|
"db-corruption-detected",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import type {
|
import type {
|
||||||
TaskStore,
|
TaskStore,
|
||||||
@@ -11,6 +12,8 @@ import type {
|
|||||||
MessageStore,
|
MessageStore,
|
||||||
RoutineStore,
|
RoutineStore,
|
||||||
GithubIssueAction,
|
GithubIssueAction,
|
||||||
|
CliSession,
|
||||||
|
NotificationPayload,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import { ChatStore, createCentralDatabase, isEphemeralAgent } from "@fusion/core";
|
import { ChatStore, createCentralDatabase, isEphemeralAgent } from "@fusion/core";
|
||||||
import { Scheduler } from "../scheduler.js";
|
import { Scheduler } from "../scheduler.js";
|
||||||
@@ -36,6 +39,7 @@ import type {
|
|||||||
ProjectRuntimeEvents,
|
ProjectRuntimeEvents,
|
||||||
} from "../project-runtime.js";
|
} from "../project-runtime.js";
|
||||||
import { runtimeLog } from "../logger.js";
|
import { runtimeLog } from "../logger.js";
|
||||||
|
import { getActiveNotificationService } from "../notifier.js";
|
||||||
import { StuckTaskDetector } from "../stuck-task-detector.js";
|
import { StuckTaskDetector } from "../stuck-task-detector.js";
|
||||||
import type { UsageLimitPauser } from "../usage-limit-detector.js";
|
import type { UsageLimitPauser } from "../usage-limit-detector.js";
|
||||||
import { SelfHealingManager, VALIDATOR_RUN_STALE_MAX_AGE_MS } from "../self-healing.js";
|
import { SelfHealingManager, VALIDATOR_RUN_STALE_MAX_AGE_MS } from "../self-healing.js";
|
||||||
@@ -53,6 +57,79 @@ import { setImmediate as setImmediateCb } from "node:timers";
|
|||||||
|
|
||||||
const yieldEventLoop = (): Promise<void> => new Promise((resolve) => setImmediateCb(resolve));
|
const yieldEventLoop = (): Promise<void> => new Promise((resolve) => setImmediateCb(resolve));
|
||||||
|
|
||||||
|
export const CLI_AGENT_AWAITING_INPUT_EVENT = "cli-agent-awaiting-input" as const;
|
||||||
|
|
||||||
|
export interface CliAgentAwaitingInputNotificationInfo {
|
||||||
|
sessionId: string;
|
||||||
|
notification: Record<string, unknown> | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableNotificationJson(value: unknown): string {
|
||||||
|
if (value === undefined) {
|
||||||
|
return "undefined";
|
||||||
|
}
|
||||||
|
if (value === null || typeof value !== "object") {
|
||||||
|
return JSON.stringify(value) ?? String(value);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return `[${value.map((entry) => stableNotificationJson(entry)).join(",")}]`;
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const fields = Object.keys(record)
|
||||||
|
.sort()
|
||||||
|
.map((key) => `${JSON.stringify(key)}:${stableNotificationJson(record[key])}`);
|
||||||
|
return `{${fields.join(",")}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCliAgentNotificationDedupeKey(input: {
|
||||||
|
projectId: string;
|
||||||
|
info: CliAgentAwaitingInputNotificationInfo;
|
||||||
|
session: CliSession | undefined;
|
||||||
|
}): string {
|
||||||
|
const notificationFingerprint = createHash("sha256")
|
||||||
|
.update(stableNotificationJson(input.info.notification ?? null))
|
||||||
|
.digest("hex")
|
||||||
|
.slice(0, 16);
|
||||||
|
const waitingEpoch = input.session?.updatedAt ?? "unknown-waiting-epoch";
|
||||||
|
return [
|
||||||
|
"cli-agent",
|
||||||
|
input.projectId,
|
||||||
|
input.info.sessionId,
|
||||||
|
CLI_AGENT_AWAITING_INPUT_EVENT,
|
||||||
|
waitingEpoch,
|
||||||
|
notificationFingerprint,
|
||||||
|
].join(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCliAgentAwaitingInputNotificationPayload(input: {
|
||||||
|
projectId: string;
|
||||||
|
info: CliAgentAwaitingInputNotificationInfo;
|
||||||
|
session: CliSession | undefined;
|
||||||
|
task: Task | undefined;
|
||||||
|
}): NotificationPayload {
|
||||||
|
const taskId = input.session?.taskId ?? undefined;
|
||||||
|
const adapterId = input.session?.adapterId;
|
||||||
|
const notificationKind = typeof input.info.notification?.kind === "string"
|
||||||
|
? input.info.notification.kind
|
||||||
|
: "waiting_on_input";
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(taskId ? { taskId } : {}),
|
||||||
|
taskTitle: input.task?.title,
|
||||||
|
taskDescription: input.task?.description,
|
||||||
|
event: CLI_AGENT_AWAITING_INPUT_EVENT,
|
||||||
|
metadata: {
|
||||||
|
sessionId: input.info.sessionId,
|
||||||
|
projectId: input.projectId,
|
||||||
|
...(adapterId ? { adapterId } : {}),
|
||||||
|
notificationKind,
|
||||||
|
notification: input.info.notification ?? null,
|
||||||
|
// FNXC:ToolPermissionNotifications 2026-06-27-00:00: CLI adapters can emit duplicate waiting-on-input records for one blocked prompt. The external notification path carries a waiting-epoch plus prompt-fingerprint key so repeated telemetry for the same blocked prompt does not spam providers, while later tool requests in the same session still notify operators.
|
||||||
|
notificationDedupeKey: buildCliAgentNotificationDedupeKey(input),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* InProcessRuntime runs a project within the main process.
|
* InProcessRuntime runs a project within the main process.
|
||||||
*
|
*
|
||||||
@@ -417,6 +494,13 @@ export class InProcessRuntime
|
|||||||
db: this.taskStore.getDatabase(),
|
db: this.taskStore.getDatabase(),
|
||||||
projectId: this.config.projectId,
|
projectId: this.config.projectId,
|
||||||
hookEndpointUrl: this.resolveCliAgentHookEndpointUrl(),
|
hookEndpointUrl: this.resolveCliAgentHookEndpointUrl(),
|
||||||
|
onNotification: (info) => {
|
||||||
|
/*
|
||||||
|
* FNXC:ToolPermissionNotifications 2026-06-27-00:00:
|
||||||
|
* CLI tool-permission prompts must notify operators through configured external providers, not only through in-app session state. Keep this callback wired to the active NotificationService so ntfy/webhook users see blocked terminal sessions.
|
||||||
|
*/
|
||||||
|
void this.dispatchCliAgentAwaitingInputNotification(info);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
runtimeLog.log("CLI Agent Executor runtime initialized");
|
runtimeLog.log("CLI Agent Executor runtime initialized");
|
||||||
} catch (cliErr) {
|
} catch (cliErr) {
|
||||||
@@ -1335,6 +1419,33 @@ export class InProcessRuntime
|
|||||||
return this.cliAgentRuntime;
|
return this.cliAgentRuntime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async dispatchCliAgentAwaitingInputNotification(
|
||||||
|
info: CliAgentAwaitingInputNotificationInfo,
|
||||||
|
): Promise<void> {
|
||||||
|
const notificationService = getActiveNotificationService();
|
||||||
|
if (!notificationService) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = this.cliAgentRuntime?.bundle.store.getSession(info.sessionId);
|
||||||
|
let task: Task | undefined;
|
||||||
|
if (session?.taskId) {
|
||||||
|
try {
|
||||||
|
task = await this.taskStore.getTask(session.taskId);
|
||||||
|
} catch {
|
||||||
|
task = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildCliAgentAwaitingInputNotificationPayload({
|
||||||
|
projectId: this.config.projectId,
|
||||||
|
info,
|
||||||
|
session,
|
||||||
|
task,
|
||||||
|
});
|
||||||
|
await notificationService.dispatch(CLI_AGENT_AWAITING_INPUT_EVENT, payload);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the dashboard CLI-agent hook ingestion endpoint URL. Prefers the
|
* Resolve the dashboard CLI-agent hook ingestion endpoint URL. Prefers the
|
||||||
* value threaded from server boot (once the listening port is known); falls
|
* value threaded from server boot (once the listening port is known); falls
|
||||||
|
|||||||
Reference in New Issue
Block a user