-
+
+
)}
diff --git a/packages/app/src/pages/session/side-panel-mcp.tsx b/packages/app/src/pages/session/side-panel-mcp.tsx
new file mode 100644
index 00000000..f1dd7fac
--- /dev/null
+++ b/packages/app/src/pages/session/side-panel-mcp.tsx
@@ -0,0 +1,29 @@
+import { Icon } from "@deepagent-code/ui/icon"
+import { IconButton } from "@deepagent-code/ui/icon-button"
+import { McpManagement } from "@/components/mcp-management"
+import { useLanguage } from "@/context/language"
+
+export function SidePanelMcp(props: { onClose: () => void }) {
+ const language = useLanguage()
+
+ return (
+
+
+
+
+ {language.t("status.popover.tab.mcp")}
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/app/src/pages/session/side-panel-oversight.tsx b/packages/app/src/pages/session/side-panel-oversight.tsx
new file mode 100644
index 00000000..5f5d5db7
--- /dev/null
+++ b/packages/app/src/pages/session/side-panel-oversight.tsx
@@ -0,0 +1,29 @@
+import { type Component } from "solid-js"
+import { IconButton } from "@deepagent-code/ui/icon-button"
+import { useLanguage } from "@/context/language"
+import { OversightDashboard } from "@/components/deepagent/oversight-dashboard"
+
+// V4.0 §D2 — Oversight as a right-side-panel tab. Mirrors SidePanelIM: owns a header + close button
+// (calls onClose), fills the panel with the OversightDashboard body. Read-mostly observability +
+// approval queue + trace + human-takeover control, all scoped to the routed workspace/directory.
+export const SidePanelOversight: Component<{ onClose: () => void }> = (props) => {
+ const language = useLanguage()
+
+ return (
+
+
+ Oversight
+
+
+
+
+
+
+ )
+}
diff --git a/packages/app/src/pages/session/side-panel-plugins.tsx b/packages/app/src/pages/session/side-panel-plugins.tsx
index 60e2c2b3..e077096d 100644
--- a/packages/app/src/pages/session/side-panel-plugins.tsx
+++ b/packages/app/src/pages/session/side-panel-plugins.tsx
@@ -1,6 +1,7 @@
import { Component, createMemo, For, type JSXElement, Show } from "solid-js"
import { useSync } from "@/context/sync"
import { useLanguage } from "@/context/language"
+import { Icon } from "@deepagent-code/ui/icon"
import { IconButton } from "@deepagent-code/ui/icon-button"
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
@@ -29,7 +30,10 @@ export const SidePanelPlugins: Component<{ onClose: () => void }> = (props) => {
return (
-
{language.t("status.popover.tab.plugins")}
+
+
+ {language.t("status.popover.tab.plugins")}
+
void }> = (props) => {
{/* ── Header ── */}
-
+
性能剖析
{
view().reviewPanel.close()
layout.fileTree.close()
- view().rightPanel.open("menu")
+ // T3.2: no more "menu" list — the always-on rail is the switcher; open a default content panel.
+ view().rightPanel.open("review")
},
}),
viewCommand({
diff --git a/packages/app/src/utils/im-client.ts b/packages/app/src/utils/im-client.ts
index 93c6decf..8b212e08 100644
--- a/packages/app/src/utils/im-client.ts
+++ b/packages/app/src/utils/im-client.ts
@@ -1,7 +1,7 @@
import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
import { authTokenFromCredentials } from "@/utils/server"
-import type { AgentDescriptor, IMGroup, IMMessage } from "@/components/im/types"
+import type { AgentDescriptor, IMAttachment, IMGroup, IMMessage } from "@/components/im/types"
export interface IMClientConfig {
/** Server base URL (e.g. http://127.0.0.1:PORT, or /w in server mode). */
@@ -82,10 +82,37 @@ export function createIMClient(config: () => IMClientConfig) {
return (await response.json()) as T
}
+ // Multipart request for file uploads — the JSON `request` helper can't carry a
+ // FormData body. Reuses the same auth + directory/workspace routing headers,
+ // but lets the browser set the multipart Content-Type (with its boundary).
+ const uploadRequest = async (
+ path: string,
+ form: FormData,
+ query?: Record,
+ ): Promise => {
+ const c = config()
+ const headers: Record = { ...authHeaders(c) }
+ if (c.directory) headers["x-deepagent-code-directory"] = c.directory
+ if (c.workspace) headers["x-deepagent-code-workspace"] = c.workspace
+ const response = await fetch(buildURL(path, query), { method: "POST", headers, body: form })
+ if (!response.ok) {
+ const text = await response.text().catch(() => "")
+ throw new Error(`IM upload failed (${response.status}): ${text || response.statusText}`)
+ }
+ return (await response.json()) as T
+ }
+
return {
listGroups: () => request(`/api/v1/im/groups`),
- createGroup: (payload: { name: string; type: "project" | "system"; projectID?: string }) =>
- request(`/api/v1/im/groups`, { method: "POST", body: payload }),
+ // §B3: `direct` groups carry a `member` (the counterparty) alongside the
+ // server-user creator. `member` is required by the backend when type ===
+ // "direct" and ignored otherwise.
+ createGroup: (payload: {
+ name: string
+ type: "project" | "system" | "direct"
+ projectID?: string
+ member?: { memberID: string; memberType: "user" | "agent" }
+ }) => request(`/api/v1/im/groups`, { method: "POST", body: payload }),
listMessages: (groupID: string, limit = 50, cursor?: string) =>
request(`/api/v1/im/groups/${groupID}/messages`, {
query: { limit: String(limit), cursor },
@@ -104,6 +131,49 @@ export function createIMClient(config: () => IMClientConfig) {
body: { readAt },
}),
listAgents: () => request(`/api/v1/im/agents`),
+ // §B3 Thread — the replies to a parent message, keyset paginated (ASC chronological).
+ listThread: (groupID: string, messageID: string, limit = 50, cursor?: string) =>
+ request(`/api/v1/im/groups/${groupID}/messages/${messageID}/thread`, {
+ query: { limit: String(limit), cursor },
+ }),
+ // §B3 搜索 — full-text + metadata search across the caller's group memberships.
+ searchMessages: (params: {
+ q: string
+ groupId?: string
+ senderType?: "user" | "agent" | "system"
+ type?: "text" | "code" | "file" | "agent_status" | "system"
+ metadataType?: string
+ limit?: number
+ cursor?: string
+ }) =>
+ request(`/api/v1/im/search`, {
+ query: {
+ q: params.q,
+ groupId: params.groupId,
+ senderType: params.senderType,
+ type: params.type,
+ metadataType: params.metadataType,
+ limit: params.limit !== undefined ? String(params.limit) : undefined,
+ cursor: params.cursor,
+ },
+ }),
+ // §B3 文件 — upload a file (multipart). `groupId`/`messageId` optionally scope it to a message.
+ uploadAttachment: (file: File, opts?: { groupId?: string; messageId?: string }) => {
+ const form = new FormData()
+ form.append("file", file, file.name)
+ if (opts?.groupId) form.append("groupId", opts.groupId)
+ if (opts?.messageId) form.append("messageId", opts.messageId)
+ return uploadRequest(`/api/v1/im/attachments`, form)
+ },
+ // §B3 文件 — list attachment records for a group / message / the workspace.
+ listAttachments: (opts?: { groupId?: string; messageId?: string; limit?: number }) =>
+ request(`/api/v1/im/attachments`, {
+ query: {
+ groupId: opts?.groupId,
+ messageId: opts?.messageId,
+ limit: opts?.limit !== undefined ? String(opts.limit) : undefined,
+ },
+ }),
// Server Edition only: set the `access_token` cookie the gateway reads to
// authenticate the WebSocket upgrade (browsers can't set WS headers). No-op
// for self-hosted (which uses the `auth_token` query instead) or outside a
diff --git a/packages/core/src/agent-gateway.ts b/packages/core/src/agent-gateway.ts
index e7881e6c..1b938a31 100644
--- a/packages/core/src/agent-gateway.ts
+++ b/packages/core/src/agent-gateway.ts
@@ -725,8 +725,14 @@ const runBackgroundLearning = (run: RunRecord, finalStatus: "completed" | "faile
// docs/34 §8: the worker stages into THIS workspace's durable project store (opened from the
// same baseDir + path the retriever reads), so learned knowledge is immediately consistent.
const durable = DeepAgentKnowledgeSource.projectStoreFor(workspacePath)
+ // Gate 3 (R3 anti-pollution): the worker must consult the SAME durable RejectedBuffer the human
+ // `reject` handler writes, so a rejected pattern is not re-learned + auto-admitted on a later run.
+ // The handler roots it at dirname(runsDir)/memory; construct the reader at the identical path.
+ const rejectedBuffer = current.runsDir
+ ? new DeepAgentPromotion.RejectedBuffer(path.join(path.dirname(current.runsDir), "memory"))
+ : undefined
return {
- worker: new DeepAgentBackgroundLearning.LearningWorker(project, projectID, durable),
+ worker: new DeepAgentBackgroundLearning.LearningWorker(project, projectID, durable, rejectedBuffer),
input: {
projectID,
sessionID: sessionId,
diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts
index 15d0eec2..233ea0b0 100644
--- a/packages/core/src/database/migration.gen.ts
+++ b/packages/core/src/database/migration.gen.ts
@@ -36,5 +36,21 @@ export const migrations = (
import("./migration/20260605042240_add_context_epoch_agent"),
import("./migration/20260704000000_im_system_tables"),
import("./migration/20260709000000_add_session_preview"),
+ import("./migration/20260711000000_deepagent_event_bus"),
+ import("./migration/20260711010000_deepagent_scheduler"),
+ import("./migration/20260711020000_im_agent_push_logs"),
+ import("./migration/20260711030000_deepagent_approval_queue"),
+ import("./migration/20260711040000_im_messages_v4_columns"),
+ import("./migration/20260711050000_deepagent_workspace_config"),
+ import("./migration/20260711060000_im_messages_fts"),
+ import("./migration/20260711080000_im_attachments"),
+ import("./migration/20260711090000_im_agent_push_digest_flushed"),
+ import("./migration/20260711100000_deepagent_event_publish_latency"),
+ import("./migration/20260712000000_deepagent_schedule_key"),
+ import("./migration/20260712010000_deepagent_event_drop"),
+ import("./migration/20260712020000_deepagent_human_takeover"),
+ import("./migration/20260712030000_deepagent_rollback"),
+ import("./migration/20260712040000_deepagent_event_drop_distinct"),
+ import("./migration/20260712050000_session_steer_queue"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
diff --git a/packages/core/src/database/migration/20260711000000_deepagent_event_bus.ts b/packages/core/src/database/migration/20260711000000_deepagent_event_bus.ts
new file mode 100644
index 00000000..7312f170
--- /dev/null
+++ b/packages/core/src/database/migration/20260711000000_deepagent_event_bus.ts
@@ -0,0 +1,87 @@
+import { Effect } from "effect"
+import type { DatabaseMigration } from "../migration"
+
+/**
+ * Migration: DeepAgent Event Bus (V4.0 §A)
+ *
+ * Creates the durable substrate for the event-driven runtime:
+ * - deepagent_event: append-only domain-event log. Publish writes here in a
+ * transaction BEFORE any dispatch ("事件先持久化,再分发", §设计原则1). The
+ * idempotency_key UNIQUE index enforces the §A3 幂等 contract at the storage
+ * layer — a re-publish with the same key is a no-op, not a second row.
+ * - deepagent_event_delivery: per-(event, subscription group) retry/DLQ tracker.
+ * Kept separate from the immutable log so retry bookkeeping never mutates the
+ * audit record. status pending → delivered | dead; dead rows are the DLQ view.
+ *
+ * These sit ALONGSIDE the existing EventV2 event/event_sequence tables (the
+ * per-aggregate sync substrate). This is the higher-level domain-event bus with
+ * retry/DLQ/priority/dedup semantics EventV2 does not model.
+ */
+export default {
+ id: "20260711000000_deepagent_event_bus",
+ up(tx) {
+ return Effect.gen(function* () {
+ yield* tx.run(`
+ CREATE TABLE IF NOT EXISTS \`deepagent_event\` (
+ \`id\` text PRIMARY KEY NOT NULL,
+ \`type\` text NOT NULL,
+ \`source\` text NOT NULL,
+ \`workspace_id\` text NOT NULL,
+ \`project_id\` text,
+ \`actor_id\` text,
+ \`correlation_id\` text,
+ \`causation_id\` text,
+ \`idempotency_key\` text NOT NULL,
+ \`priority\` text NOT NULL,
+ \`payload\` text,
+ \`created_at\` integer NOT NULL
+ );
+ `)
+
+ // §A3 幂等: storage-enforced dedupe.
+ yield* tx.run(`
+ CREATE UNIQUE INDEX IF NOT EXISTS \`deepagent_event_idempotency_idx\`
+ ON \`deepagent_event\` (\`idempotency_key\`);
+ `)
+ // §A4 去重窗口 + §F2 trace.
+ yield* tx.run(`
+ CREATE INDEX IF NOT EXISTS \`deepagent_event_type_created_idx\`
+ ON \`deepagent_event\` (\`type\`, \`created_at\`);
+ `)
+ yield* tx.run(`
+ CREATE INDEX IF NOT EXISTS \`deepagent_event_correlation_idx\`
+ ON \`deepagent_event\` (\`correlation_id\`, \`created_at\`);
+ `)
+ // §A3 保留期: workspace-scoped retention sweep.
+ yield* tx.run(`
+ CREATE INDEX IF NOT EXISTS \`deepagent_event_workspace_created_idx\`
+ ON \`deepagent_event\` (\`workspace_id\`, \`created_at\`);
+ `)
+
+ yield* tx.run(`
+ CREATE TABLE IF NOT EXISTS \`deepagent_event_delivery\` (
+ \`event_id\` text NOT NULL,
+ \`subscription_group\` text NOT NULL,
+ \`status\` text NOT NULL,
+ \`attempts\` integer NOT NULL,
+ \`last_error\` text,
+ \`next_attempt_at\` integer,
+ \`created_at\` integer NOT NULL,
+ \`updated_at\` integer NOT NULL,
+ FOREIGN KEY (\`event_id\`) REFERENCES \`deepagent_event\`(\`id\`) ON DELETE CASCADE
+ );
+ `)
+
+ // one delivery tracker per (event, group).
+ yield* tx.run(`
+ CREATE UNIQUE INDEX IF NOT EXISTS \`deepagent_event_delivery_unique_idx\`
+ ON \`deepagent_event_delivery\` (\`event_id\`, \`subscription_group\`);
+ `)
+ // retry scan: pending rows whose backoff has elapsed, oldest first.
+ yield* tx.run(`
+ CREATE INDEX IF NOT EXISTS \`deepagent_event_delivery_due_idx\`
+ ON \`deepagent_event_delivery\` (\`status\`, \`next_attempt_at\`);
+ `)
+ })
+ },
+} satisfies DatabaseMigration.Migration
diff --git a/packages/core/src/database/migration/20260711010000_deepagent_scheduler.ts b/packages/core/src/database/migration/20260711010000_deepagent_scheduler.ts
new file mode 100644
index 00000000..bfbe90a4
--- /dev/null
+++ b/packages/core/src/database/migration/20260711010000_deepagent_scheduler.ts
@@ -0,0 +1,47 @@
+import { Effect } from "effect"
+import type { DatabaseMigration } from "../migration"
+
+/**
+ * Migration: DeepAgent Scheduler (V4.0 §A4)
+ *
+ * Creates `deepagent_schedule` — the DURABLE schedule store. Unlike BackgroundJob
+ * (explicitly non-durable, loses live jobs on restart), the V4.0 Scheduler must
+ * survive process restarts: a delayed event scheduled before a crash still fires
+ * after recovery, and periodic scans resume on cadence. One row per schedule;
+ * `kind` distinguishes delay / periodic / condition triggers. The tick loop
+ * (deepagent-code) scans due rows and publishes their templated event via the
+ * Event Bus.
+ */
+export default {
+ id: "20260711010000_deepagent_scheduler",
+ up(tx) {
+ return Effect.gen(function* () {
+ yield* tx.run(`
+ CREATE TABLE IF NOT EXISTS \`deepagent_schedule\` (
+ \`id\` text PRIMARY KEY NOT NULL,
+ \`workspace_id\` text NOT NULL,
+ \`kind\` text NOT NULL,
+ \`status\` text NOT NULL,
+ \`event_template\` text NOT NULL,
+ \`fire_at\` integer,
+ \`interval_ms\` integer,
+ \`condition\` text,
+ \`last_fired_at\` integer,
+ \`created_at\` integer NOT NULL,
+ \`updated_at\` integer NOT NULL
+ );
+ `)
+
+ // tick scan: active schedules whose next fire/check time has elapsed, oldest first.
+ yield* tx.run(`
+ CREATE INDEX IF NOT EXISTS \`deepagent_schedule_due_idx\`
+ ON \`deepagent_schedule\` (\`status\`, \`fire_at\`);
+ `)
+ // per-workspace listing + retention.
+ yield* tx.run(`
+ CREATE INDEX IF NOT EXISTS \`deepagent_schedule_workspace_idx\`
+ ON \`deepagent_schedule\` (\`workspace_id\`, \`status\`);
+ `)
+ })
+ },
+} satisfies DatabaseMigration.Migration
diff --git a/packages/core/src/database/migration/20260711020000_im_agent_push_logs.ts b/packages/core/src/database/migration/20260711020000_im_agent_push_logs.ts
new file mode 100644
index 00000000..e53462c9
--- /dev/null
+++ b/packages/core/src/database/migration/20260711020000_im_agent_push_logs.ts
@@ -0,0 +1,50 @@
+import { Effect } from "effect"
+import type { DatabaseMigration } from "../migration"
+
+/**
+ * Migration: IM Agent Push Logs (V4.0 §B4)
+ *
+ * Creates `im_agent_push_logs` — the durable audit + rate-limit-accounting log
+ * for agent PROACTIVE pushes (§B2). One row per push attempt (delivered, held
+ * for digest, or blocked), so the per-agent-per-group-per-hour rate window is
+ * countable and Oversight can trace what an agent pushed and why. Kept separate
+ * from im_messages so the push audit survives independent of the delivered row.
+ */
+export default {
+ id: "20260711020000_im_agent_push_logs",
+ up(tx) {
+ return Effect.gen(function* () {
+ yield* tx.run(`
+ CREATE TABLE IF NOT EXISTS \`im_agent_push_logs\` (
+ \`id\` text PRIMARY KEY NOT NULL,
+ \`workspace_id\` text NOT NULL,
+ \`group_id\` text NOT NULL,
+ \`agent_id\` text NOT NULL,
+ \`reason\` text NOT NULL,
+ \`priority\` text NOT NULL,
+ \`decision\` text NOT NULL,
+ \`idempotency_key\` text NOT NULL,
+ \`message_id\` text,
+ \`content\` text,
+ \`created_at\` integer NOT NULL
+ );
+ `)
+
+ // §B2 去重: storage-enforced one-delivery-per idempotency key.
+ yield* tx.run(`
+ CREATE UNIQUE INDEX IF NOT EXISTS \`idx_im_agent_push_logs_idempotency\`
+ ON \`im_agent_push_logs\` (\`idempotency_key\`);
+ `)
+ // §B2 rate-limit scan + Oversight timeline: this agent's recent pushes to a group.
+ yield* tx.run(`
+ CREATE INDEX IF NOT EXISTS \`idx_im_agent_push_logs_agent_time\`
+ ON \`im_agent_push_logs\` (\`agent_id\`, \`group_id\`, \`created_at\`);
+ `)
+ // per-workspace audit sweep.
+ yield* tx.run(`
+ CREATE INDEX IF NOT EXISTS \`idx_im_agent_push_logs_workspace\`
+ ON \`im_agent_push_logs\` (\`workspace_id\`, \`created_at\`);
+ `)
+ })
+ },
+} satisfies DatabaseMigration.Migration
diff --git a/packages/core/src/database/migration/20260711030000_deepagent_approval_queue.ts b/packages/core/src/database/migration/20260711030000_deepagent_approval_queue.ts
new file mode 100644
index 00000000..f7deb6cb
--- /dev/null
+++ b/packages/core/src/database/migration/20260711030000_deepagent_approval_queue.ts
@@ -0,0 +1,44 @@
+import { Effect } from "effect"
+import type { DatabaseMigration } from "../migration"
+
+/**
+ * Migration: DeepAgent Approval Queue (V4.0 §D2)
+ *
+ * Creates `deepagent_approval_queue` — the durable human-decision sink for
+ * events that escalate (goal.needs_human / goal.rolled_back / panel.verdict
+ * needs_human). One row per raising event (UNIQUE(event_id) → a re-delivered
+ * event never double-queues); a human resolves it in the Oversight Dashboard.
+ */
+export default {
+ id: "20260711030000_deepagent_approval_queue",
+ up(tx) {
+ return Effect.gen(function* () {
+ yield* tx.run(`
+ CREATE TABLE IF NOT EXISTS \`deepagent_approval_queue\` (
+ \`id\` text PRIMARY KEY NOT NULL,
+ \`workspace_id\` text NOT NULL,
+ \`event_id\` text NOT NULL,
+ \`event_type\` text NOT NULL,
+ \`correlation_id\` text,
+ \`summary\` text NOT NULL,
+ \`status\` text NOT NULL,
+ \`decision\` text,
+ \`resolved_by\` text,
+ \`resolved_at\` integer,
+ \`created_at\` integer NOT NULL
+ );
+ `)
+
+ // §D2 去重: one queue item per raising event.
+ yield* tx.run(`
+ CREATE UNIQUE INDEX IF NOT EXISTS \`deepagent_approval_queue_event_idx\`
+ ON \`deepagent_approval_queue\` (\`event_id\`);
+ `)
+ // Dashboard: a workspace's pending items.
+ yield* tx.run(`
+ CREATE INDEX IF NOT EXISTS \`deepagent_approval_queue_pending_idx\`
+ ON \`deepagent_approval_queue\` (\`workspace_id\`, \`status\`, \`created_at\`);
+ `)
+ })
+ },
+} satisfies DatabaseMigration.Migration
diff --git a/packages/core/src/database/migration/20260711040000_im_messages_v4_columns.ts b/packages/core/src/database/migration/20260711040000_im_messages_v4_columns.ts
new file mode 100644
index 00000000..4975fb64
--- /dev/null
+++ b/packages/core/src/database/migration/20260711040000_im_messages_v4_columns.ts
@@ -0,0 +1,42 @@
+import { Effect } from "effect"
+import type { DatabaseMigration } from "../migration"
+
+/**
+ * Migration: IM messages V4.0 columns (§B4)
+ *
+ * Adds the event-driven columns to `im_messages` WITHOUT breaking V3.8 queries
+ * (§H compatibility — both nullable, ADD COLUMN is backward-compatible):
+ * - event_id: the DeepAgent Event Bus event a message was produced from.
+ * - delivery_status: pending | delivered | failed (event-driven messages only).
+ * Plus the §B4 thread-pagination + event-lookup indexes.
+ */
+export default {
+ id: "20260711040000_im_messages_v4_columns",
+ up(tx) {
+ return Effect.gen(function* () {
+ // ADD COLUMN errors if the column exists (SQLite has no ADD COLUMN IF NOT EXISTS), so guard each
+ // via a table_info check — mirrors 20260709000000_add_session_preview.
+ const cols = yield* tx.all<{ name: string }>(`PRAGMA table_info(\`im_messages\`)`)
+ const has = (name: string) => cols.some((c) => c.name === name)
+ if (!has("event_id")) {
+ yield* tx.run(`ALTER TABLE \`im_messages\` ADD COLUMN \`event_id\` text;`)
+ }
+ if (!has("delivery_status")) {
+ yield* tx.run(`ALTER TABLE \`im_messages\` ADD COLUMN \`delivery_status\` text;`)
+ }
+
+ // §B4 thread pagination: (group_id, reply_to_id, created_at). The spec's partial WHERE
+ // deleted_at IS NULL predicate is dropped here (this repo's index builder omits it on the active
+ // index too); the query filters deleted_at explicitly.
+ yield* tx.run(`
+ CREATE INDEX IF NOT EXISTS \`idx_im_messages_thread\`
+ ON \`im_messages\` (\`group_id\`, \`reply_to_id\`, \`created_at\`);
+ `)
+ // §B4 event linkage lookup.
+ yield* tx.run(`
+ CREATE INDEX IF NOT EXISTS \`idx_im_messages_event\`
+ ON \`im_messages\` (\`event_id\`);
+ `)
+ })
+ },
+} satisfies DatabaseMigration.Migration
diff --git a/packages/core/src/database/migration/20260711050000_deepagent_workspace_config.ts b/packages/core/src/database/migration/20260711050000_deepagent_workspace_config.ts
new file mode 100644
index 00000000..dc33c799
--- /dev/null
+++ b/packages/core/src/database/migration/20260711050000_deepagent_workspace_config.ts
@@ -0,0 +1,27 @@
+import { Effect } from "effect"
+import type { DatabaseMigration } from "../migration"
+
+/**
+ * Migration: DeepAgent per-workspace config (V4.0)
+ *
+ * Creates `deepagent_workspace_config` — one row per workspace holding the V4
+ * policy knobs (retention days, quiet-hours window, rate-limit overrides,
+ * trusted event sources) as a single versioned JSON blob. An absent row means
+ * "use code defaults", so this is fully backward-compatible: existing workspaces
+ * keep the lenient defaults until a config is explicitly written.
+ */
+export default {
+ id: "20260711050000_deepagent_workspace_config",
+ up(tx) {
+ return Effect.gen(function* () {
+ yield* tx.run(`
+ CREATE TABLE IF NOT EXISTS \`deepagent_workspace_config\` (
+ \`workspace_id\` text PRIMARY KEY NOT NULL,
+ \`config\` text NOT NULL,
+ \`created_at\` integer NOT NULL,
+ \`updated_at\` integer NOT NULL
+ );
+ `)
+ })
+ },
+} satisfies DatabaseMigration.Migration
diff --git a/packages/core/src/database/migration/20260711060000_im_messages_fts.ts b/packages/core/src/database/migration/20260711060000_im_messages_fts.ts
new file mode 100644
index 00000000..44ab85bf
--- /dev/null
+++ b/packages/core/src/database/migration/20260711060000_im_messages_fts.ts
@@ -0,0 +1,85 @@
+import { Effect } from "effect"
+import type { DatabaseMigration } from "../migration"
+
+/**
+ * Migration: IM message full-text search (§B3 搜索)
+ *
+ * Creates an FTS5 virtual table `im_messages_fts` mirroring `im_messages.content`, plus triggers that
+ * keep it synced with the base table. `im_messages` has a TEXT primary key (not an integer rowid), so
+ * an external-content FTS5 table is not a natural fit; instead this is an own-content FTS5 table with an
+ * extra UNINDEXED `msg_id` column carrying the message id, which the search query JOINs back to
+ * `im_messages` for the full row + permission scoping.
+ *
+ * The FTS table holds ONLY active (deleted_at IS NULL) messages — the delete/soft-delete triggers evict
+ * rows — so a soft-deleted message never surfaces in search even before the query's explicit
+ * `deleted_at IS NULL` filter.
+ *
+ * FALLBACK: if this SQLite build lacks the FTS5 module, `CREATE VIRTUAL TABLE` throws. We catch that and
+ * skip FTS setup so the migration still completes; the repository detects the missing table at runtime
+ * and falls back to a LIKE-based scan (the search method + endpoint work either way).
+ */
+export default {
+ id: "20260711060000_im_messages_fts",
+ up(tx) {
+ return Effect.gen(function* () {
+ yield* Effect.gen(function* () {
+ // Own-content FTS5 table. `msg_id UNINDEXED` stores the message id without tokenizing it.
+ yield* tx.run(`
+ CREATE VIRTUAL TABLE IF NOT EXISTS \`im_messages_fts\` USING fts5(
+ content,
+ msg_id UNINDEXED
+ );
+ `)
+
+ // Keep the FTS index synced with the base table. All triggers key off the message id in the
+ // UNINDEXED column (a regular own-content FTS5 table supports arbitrary WHERE by that column).
+ yield* tx.run(`
+ CREATE TRIGGER IF NOT EXISTS \`im_messages_fts_ai\`
+ AFTER INSERT ON \`im_messages\`
+ WHEN new.deleted_at IS NULL
+ BEGIN
+ INSERT INTO \`im_messages_fts\`(content, msg_id) VALUES (new.content, new.id);
+ END;
+ `)
+ // Content edit: refresh the indexed text for that message.
+ yield* tx.run(`
+ CREATE TRIGGER IF NOT EXISTS \`im_messages_fts_au_content\`
+ AFTER UPDATE OF content ON \`im_messages\`
+ WHEN new.deleted_at IS NULL
+ BEGIN
+ DELETE FROM \`im_messages_fts\` WHERE msg_id = new.id;
+ INSERT INTO \`im_messages_fts\`(content, msg_id) VALUES (new.content, new.id);
+ END;
+ `)
+ // Soft-delete: evict when deleted_at flips to non-null; re-index on un-delete.
+ yield* tx.run(`
+ CREATE TRIGGER IF NOT EXISTS \`im_messages_fts_au_delete\`
+ AFTER UPDATE OF deleted_at ON \`im_messages\`
+ BEGIN
+ DELETE FROM \`im_messages_fts\` WHERE msg_id = new.id;
+ INSERT INTO \`im_messages_fts\`(content, msg_id)
+ SELECT new.content, new.id WHERE new.deleted_at IS NULL;
+ END;
+ `)
+ // Hard delete: evict.
+ yield* tx.run(`
+ CREATE TRIGGER IF NOT EXISTS \`im_messages_fts_ad\`
+ AFTER DELETE ON \`im_messages\`
+ BEGIN
+ DELETE FROM \`im_messages_fts\` WHERE msg_id = old.id;
+ END;
+ `)
+
+ // Backfill existing active messages (no-op on a fresh install).
+ yield* tx.run(`
+ INSERT INTO \`im_messages_fts\`(content, msg_id)
+ SELECT content, id FROM \`im_messages\` WHERE deleted_at IS NULL;
+ `)
+ }).pipe(
+ // FTS5 unavailable in this build → skip (repository uses the LIKE fallback). Any partial state is
+ // harmless: the runtime feature-detects the table's presence.
+ Effect.catchCause(() => Effect.void),
+ )
+ })
+ },
+} satisfies DatabaseMigration.Migration
diff --git a/packages/core/src/database/migration/20260711080000_im_attachments.ts b/packages/core/src/database/migration/20260711080000_im_attachments.ts
new file mode 100644
index 00000000..547f0b4c
--- /dev/null
+++ b/packages/core/src/database/migration/20260711080000_im_attachments.ts
@@ -0,0 +1,49 @@
+import { Effect } from "effect"
+import type { DatabaseMigration } from "../migration"
+
+/**
+ * Migration: IM file attachments (§B3 文件上传 / §B4 im_attachments)
+ *
+ * A file record is decoupled from a message: `message_id` is nullable so a file can exist before, or
+ * without, any message. `storage_path` is a server-derived absolute path on local disk (never the
+ * client filename). `checksum` is the sha256 hex digest of the stored bytes. Soft delete via
+ * `deleted_at` matches the rest of the IM schema.
+ */
+export default {
+ id: "20260711080000_im_attachments",
+ up(tx) {
+ return Effect.gen(function* () {
+ yield* tx.run(`
+ CREATE TABLE IF NOT EXISTS \`im_attachments\` (
+ \`id\` text PRIMARY KEY NOT NULL,
+ \`workspace_id\` text NOT NULL,
+ \`project_id\` text,
+ \`group_id\` text,
+ \`message_id\` text,
+ \`uploaded_by\` text NOT NULL,
+ \`storage_path\` text NOT NULL,
+ \`filename\` text NOT NULL,
+ \`mime\` text NOT NULL,
+ \`size_bytes\` integer NOT NULL,
+ \`checksum\` text NOT NULL,
+ \`created_at\` integer NOT NULL,
+ \`deleted_at\` integer,
+ FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
+ );
+ `)
+
+ yield* tx.run(`
+ CREATE INDEX IF NOT EXISTS \`idx_im_attachments_workspace\`
+ ON \`im_attachments\` (\`workspace_id\`, \`created_at\`);
+ `)
+ yield* tx.run(`
+ CREATE INDEX IF NOT EXISTS \`idx_im_attachments_message\`
+ ON \`im_attachments\` (\`message_id\`);
+ `)
+ yield* tx.run(`
+ CREATE INDEX IF NOT EXISTS \`idx_im_attachments_group\`
+ ON \`im_attachments\` (\`group_id\`);
+ `)
+ })
+ },
+} satisfies DatabaseMigration.Migration
diff --git a/packages/core/src/database/migration/20260711090000_im_agent_push_digest_flushed.ts b/packages/core/src/database/migration/20260711090000_im_agent_push_digest_flushed.ts
new file mode 100644
index 00000000..d19bf11e
--- /dev/null
+++ b/packages/core/src/database/migration/20260711090000_im_agent_push_digest_flushed.ts
@@ -0,0 +1,34 @@
+import { Effect } from "effect"
+import type { DatabaseMigration } from "../migration"
+
+/**
+ * Migration: agent-push digest flush marker (§B2/§E4)
+ *
+ * Adds `digest_flushed_at` to `im_agent_push_logs`. A `decision='digest'` push is HELD during quiet
+ * hours (no im_messages row is written by agent-push); the DigestBuilder later batches the held pushes
+ * into one summary per group when quiet hours end. This column is that "already flushed" marker:
+ * NULL → held, awaiting the next quiet-hours-end digest flush.
+ * → the epoch ms at which the DigestBuilder delivered it in a batch (never re-delivered).
+ *
+ * Nullable + ADD COLUMN is backward-compatible (§H): existing rows read NULL, and the digest builder
+ * treats pre-migration digest rows as unflushed (they'll flush on the next pass). Guarded via a
+ * table_info check because SQLite has no ADD COLUMN IF NOT EXISTS (mirrors 20260711040000).
+ */
+export default {
+ id: "20260711090000_im_agent_push_digest_flushed",
+ up(tx) {
+ return Effect.gen(function* () {
+ const cols = yield* tx.all<{ name: string }>(`PRAGMA table_info(\`im_agent_push_logs\`)`)
+ const has = (name: string) => cols.some((c) => c.name === name)
+ if (!has("digest_flushed_at")) {
+ yield* tx.run(`ALTER TABLE \`im_agent_push_logs\` ADD COLUMN \`digest_flushed_at\` integer;`)
+ }
+ // §E4 digest scan: unflushed held-digest rows per workspace, so the builder finds pending digests
+ // without a full-table scan (WHERE decision='digest' AND digest_flushed_at IS NULL).
+ yield* tx.run(`
+ CREATE INDEX IF NOT EXISTS \`idx_im_agent_push_logs_digest_pending\`
+ ON \`im_agent_push_logs\` (\`workspace_id\`, \`decision\`, \`digest_flushed_at\`);
+ `)
+ })
+ },
+} satisfies DatabaseMigration.Migration
diff --git a/packages/core/src/database/migration/20260711100000_deepagent_event_publish_latency.ts b/packages/core/src/database/migration/20260711100000_deepagent_event_publish_latency.ts
new file mode 100644
index 00000000..bf444179
--- /dev/null
+++ b/packages/core/src/database/migration/20260711100000_deepagent_event_publish_latency.ts
@@ -0,0 +1,26 @@
+import { Effect } from "effect"
+import type { DatabaseMigration } from "../migration"
+
+/**
+ * Migration: DeepAgent event publish-latency column (§F1 event_publish_latency_ms)
+ *
+ * Adds a nullable `publish_latency_ms` integer to `deepagent_event`. The Event Bus writes the
+ * wall-clock delta (measured with the injected clock) around the persist transaction so Observability
+ * can compute the §F1 event_publish_latency_ms P50/P95 histogram. ADD COLUMN is backward-compatible
+ * (§H): the column is nullable, so pre-V4.0 rows (and any producer that doesn't populate it) read null
+ * and are excluded from the percentile samples.
+ */
+export default {
+ id: "20260711100000_deepagent_event_publish_latency",
+ up(tx) {
+ return Effect.gen(function* () {
+ // ADD COLUMN errors if the column exists (SQLite has no ADD COLUMN IF NOT EXISTS), so guard via
+ // a table_info check — mirrors 20260711040000_im_messages_v4_columns.
+ const cols = yield* tx.all<{ name: string }>(`PRAGMA table_info(\`deepagent_event\`)`)
+ const has = (name: string) => cols.some((c) => c.name === name)
+ if (!has("publish_latency_ms")) {
+ yield* tx.run(`ALTER TABLE \`deepagent_event\` ADD COLUMN \`publish_latency_ms\` integer;`)
+ }
+ })
+ },
+} satisfies DatabaseMigration.Migration
diff --git a/packages/core/src/database/migration/20260712000000_deepagent_schedule_key.ts b/packages/core/src/database/migration/20260712000000_deepagent_schedule_key.ts
new file mode 100644
index 00000000..46870fe5
--- /dev/null
+++ b/packages/core/src/database/migration/20260712000000_deepagent_schedule_key.ts
@@ -0,0 +1,30 @@
+import { Effect } from "effect"
+import type { DatabaseMigration } from "../migration"
+
+/**
+ * Migration: DeepAgent Scheduler idempotency key (V4.0 §A4, P1.6 follow-up)
+ *
+ * Adds a nullable `schedule_key` column + a UNIQUE index to `deepagent_schedule`.
+ * The key is a stable dedupe identity for schedules that must be registered
+ * idempotently across process restarts (the boot-time bootstrap schedules).
+ * NULL for ordinary ad-hoc schedules, and because SQLite treats NULLs as
+ * distinct in a UNIQUE index, the constraint applies ONLY to keyed rows — a
+ * natural partial-unique. This closes the multi-process TOCTOU on the
+ * list-then-insert bootstrap: a concurrent duplicate insert of the same key is
+ * rejected at the DB layer (paired with onConflictDoNothing in the service).
+ *
+ * Backward compatible: existing rows get schedule_key = NULL (unconstrained).
+ */
+export default {
+ id: "20260712000000_deepagent_schedule_key",
+ up(tx) {
+ return Effect.gen(function* () {
+ yield* tx.run(`ALTER TABLE \`deepagent_schedule\` ADD \`schedule_key\` text;`)
+ // at most one row per non-null schedule_key; NULLs are distinct so unkeyed rows are unconstrained.
+ yield* tx.run(`
+ CREATE UNIQUE INDEX IF NOT EXISTS \`deepagent_schedule_key_uidx\`
+ ON \`deepagent_schedule\` (\`schedule_key\`);
+ `)
+ })
+ },
+} satisfies DatabaseMigration.Migration
diff --git a/packages/core/src/database/migration/20260712010000_deepagent_event_drop.ts b/packages/core/src/database/migration/20260712010000_deepagent_event_drop.ts
new file mode 100644
index 00000000..4c08f30b
--- /dev/null
+++ b/packages/core/src/database/migration/20260712010000_deepagent_event_drop.ts
@@ -0,0 +1,36 @@
+import { Effect } from "effect"
+import type { DatabaseMigration } from "../migration"
+
+/**
+ * Migration: DeepAgent event-drop log (§A4 event_dropped as a persisted metric)
+ *
+ * Creates `deepagent_event_drop`, an append-only audit log of events the §A4 router SHED under
+ * backpressure. Until now a drop was LOG-ONLY (event-dispatcher.ts) — unqueryable, unlike the DLQ which
+ * is real SQL. This table lets Observability aggregate `event_dropped_total` (by reason) exactly the way
+ * it aggregates `dlq_events_total` (by dead delivery).
+ *
+ * DESIGN: no FK to `deepagent_event` — a drop is an audit COUNTER that must survive the retention sweep
+ * of the event it references (the count is the signal). Workspace-scoped index for the windowed metric.
+ */
+export default {
+ id: "20260712010000_deepagent_event_drop",
+ up(tx) {
+ return Effect.gen(function* () {
+ yield* tx.run(`
+ CREATE TABLE IF NOT EXISTS \`deepagent_event_drop\` (
+ \`id\` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
+ \`event_id\` text NOT NULL,
+ \`workspace_id\` text NOT NULL,
+ \`reason\` text NOT NULL,
+ \`priority\` text NOT NULL,
+ \`created_at\` integer NOT NULL
+ );
+ `)
+ // §F1 event_dropped_total: workspace-scoped, windowed aggregation.
+ yield* tx.run(`
+ CREATE INDEX IF NOT EXISTS \`deepagent_event_drop_workspace_created_idx\`
+ ON \`deepagent_event_drop\` (\`workspace_id\`, \`created_at\`);
+ `)
+ })
+ },
+} satisfies DatabaseMigration.Migration
diff --git a/packages/core/src/database/migration/20260712020000_deepagent_human_takeover.ts b/packages/core/src/database/migration/20260712020000_deepagent_human_takeover.ts
new file mode 100644
index 00000000..ab7debf4
--- /dev/null
+++ b/packages/core/src/database/migration/20260712020000_deepagent_human_takeover.ts
@@ -0,0 +1,39 @@
+import { Effect } from "effect"
+import type { DatabaseMigration } from "../migration"
+
+/**
+ * Migration: DeepAgent Human Takeover log (V4.0 §D2/§F)
+ *
+ * Creates `deepagent_human_takeover` — the append-only audit log of moments a
+ * human stepped in over an agent (pausing/reverting its session, or claiming a
+ * branch/session it was driving). Backs the §D2 Takeover surface and the §F
+ * `human_takeover_total` metric. One row per takeover; never mutated (a takeover
+ * is a past fact, unlike the mutable Approval Queue).
+ *
+ * Timestamp note (P3.10): placed at 20260712020000 — after 20260712000000 and
+ * after P3.13's migration slot — so the two parallel worktrees do not collide.
+ */
+export default {
+ id: "20260712020000_deepagent_human_takeover",
+ up(tx) {
+ return Effect.gen(function* () {
+ yield* tx.run(`
+ CREATE TABLE IF NOT EXISTS \`deepagent_human_takeover\` (
+ \`id\` text PRIMARY KEY NOT NULL,
+ \`workspace_id\` text NOT NULL,
+ \`session_id\` text,
+ \`agent_id\` text,
+ \`actor_id\` text,
+ \`reason\` text,
+ \`created_at\` integer NOT NULL
+ );
+ `)
+
+ // §F metric + §D2 surface: a workspace's takeovers over a window, newest first.
+ yield* tx.run(`
+ CREATE INDEX IF NOT EXISTS \`deepagent_human_takeover_workspace_idx\`
+ ON \`deepagent_human_takeover\` (\`workspace_id\`, \`created_at\`);
+ `)
+ })
+ },
+} satisfies DatabaseMigration.Migration
diff --git a/packages/core/src/database/migration/20260712030000_deepagent_rollback.ts b/packages/core/src/database/migration/20260712030000_deepagent_rollback.ts
new file mode 100644
index 00000000..9f2e9b89
--- /dev/null
+++ b/packages/core/src/database/migration/20260712030000_deepagent_rollback.ts
@@ -0,0 +1,41 @@
+import { Effect } from "effect"
+import type { DatabaseMigration } from "../migration"
+
+/**
+ * Migration: DeepAgent Rollback audit log (V4.0 §D2/§F)
+ *
+ * Creates `deepagent_rollback` — the append-only audit log of moments a human
+ * rolled back an agent-produced change over a session (via SessionRevert, the
+ * same primitive the goal loop uses). Backs the §D2 Rollback surface (paired
+ * with the Takeover surface) and the §F `rollback_total` metric. One row per
+ * rollback; never mutated (a rollback is a past fact, unlike the mutable
+ * Approval Queue). Mirrors deepagent_human_takeover, plus an `outcome` column
+ * ("reverted" | "noop") since a rollback can be a no-op with nothing to revert.
+ *
+ * Timestamp note (P4.4): placed at 20260712030000 — AFTER 20260712020000
+ * (deepagent_human_takeover, P3.10) so it applies after the takeover slot.
+ */
+export default {
+ id: "20260712030000_deepagent_rollback",
+ up(tx) {
+ return Effect.gen(function* () {
+ yield* tx.run(`
+ CREATE TABLE IF NOT EXISTS \`deepagent_rollback\` (
+ \`id\` text PRIMARY KEY NOT NULL,
+ \`workspace_id\` text NOT NULL,
+ \`session_id\` text NOT NULL,
+ \`actor_id\` text,
+ \`reason\` text,
+ \`outcome\` text NOT NULL,
+ \`created_at\` integer NOT NULL
+ );
+ `)
+
+ // §F metric + §D2 surface: a workspace's rollbacks over a window, newest first.
+ yield* tx.run(`
+ CREATE INDEX IF NOT EXISTS \`deepagent_rollback_workspace_idx\`
+ ON \`deepagent_rollback\` (\`workspace_id\`, \`created_at\`);
+ `)
+ })
+ },
+} satisfies DatabaseMigration.Migration
diff --git a/packages/core/src/database/migration/20260712040000_deepagent_event_drop_distinct.ts b/packages/core/src/database/migration/20260712040000_deepagent_event_drop_distinct.ts
new file mode 100644
index 00000000..a6006e8b
--- /dev/null
+++ b/packages/core/src/database/migration/20260712040000_deepagent_event_drop_distinct.ts
@@ -0,0 +1,43 @@
+import { Effect } from "effect"
+import type { DatabaseMigration } from "../migration"
+
+/**
+ * Migration: DeepAgent event-drop DISTINCT-event semantics (§A4 event_dropped_total)
+ *
+ * P3.13 (migration 20260712010000) added `deepagent_event_drop` as an append-only audit log. But the §A4
+ * backpressure path calls recordDrop on EVERY shed pass, so one logical event shed→nacked→re-shed ×N wrote
+ * N rows → event_dropped_total counted shed-ATTEMPTS, not DISTINCT events.
+ *
+ * FIX: a UNIQUE index on `event_id` (alone). recordDrop now inserts with onConflictDoNothing on this index,
+ * so a re-shed of the same event is a no-op → at most one drop row per event → COUNT(*) == distinct events
+ * shed. Unique on event_id (not event_id+reason) is deliberate: an event is shed for one reason under §A4
+ * backpressure and the first drop is the signal.
+ *
+ * DEDUPE-BEFORE-INDEX (migration robustness): a dev/beta DB that already ran 20260712010000 (create table)
+ * with the V4 flags ON and shed the SAME event multiple times under backpressure ALREADY holds duplicate
+ * event_id rows. Creating the UNIQUE index directly on such a table throws `UNIQUE constraint failed`,
+ * aborting the migration transaction → the app fails to start and retries every restart (id never records).
+ * So we DELETE the duplicates FIRST — keeping the first row (MIN(rowid)) per event_id — then build the
+ * index. This makes the migration safe in ANY pre-existing data state (with or without duplicates), not
+ * just a fresh install. On a fresh / duplicate-free table the DELETE is a harmless no-op. CREATE UNIQUE
+ * INDEX IF NOT EXISTS stays idempotent.
+ */
+export default {
+ id: "20260712040000_deepagent_event_drop_distinct",
+ up(tx) {
+ return Effect.gen(function* () {
+ // 1) Collapse any historical duplicate event_id rows to one (the earliest, by rowid) BEFORE the
+ // unique index — otherwise the index build throws on a DB that shed the same event multiple times.
+ // No-op on a fresh / already-distinct table.
+ yield* tx.run(`
+ DELETE FROM \`deepagent_event_drop\`
+ WHERE rowid NOT IN (SELECT MIN(rowid) FROM \`deepagent_event_drop\` GROUP BY \`event_id\`);
+ `)
+ // 2) Now the table is guaranteed one-row-per-event_id, so the UNIQUE index applies cleanly.
+ yield* tx.run(`
+ CREATE UNIQUE INDEX IF NOT EXISTS \`deepagent_event_drop_event_id_idx\`
+ ON \`deepagent_event_drop\` (\`event_id\`);
+ `)
+ })
+ },
+} satisfies DatabaseMigration.Migration
diff --git a/packages/core/src/database/migration/20260712050000_session_steer_queue.ts b/packages/core/src/database/migration/20260712050000_session_steer_queue.ts
new file mode 100644
index 00000000..7abcc003
--- /dev/null
+++ b/packages/core/src/database/migration/20260712050000_session_steer_queue.ts
@@ -0,0 +1,30 @@
+import { Effect } from "effect"
+import type { DatabaseMigration } from "../migration"
+
+// V4.1 §S1.1: durable mid-turn steer queue. A separate, plain (non-event-sourced) buffer for user
+// messages that arrive while a session is busy. The live turn loop (SessionPrompt.runLoop) drains it
+// at each model-request boundary and persists each steer as an ordinary tail user message.
+// Consume-once is enforced by `consumed_seq` (NULL == pending); `seq` is the per-session monotonic
+// admission order used to drain in send-order.
+export default {
+ id: "20260712050000_session_steer_queue",
+ up(tx) {
+ return Effect.gen(function* () {
+ yield* tx.run(`
+ CREATE TABLE \`session_steer\` (
+ \`seq\` integer PRIMARY KEY AUTOINCREMENT,
+ \`id\` text NOT NULL UNIQUE,
+ \`session_id\` text NOT NULL,
+ \`prompt\` text NOT NULL,
+ \`delivery\` text NOT NULL,
+ \`consumed_seq\` integer,
+ \`time_created\` integer NOT NULL,
+ CONSTRAINT \`fk_session_steer_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
+ );
+ `)
+ yield* tx.run(
+ `CREATE INDEX \`session_steer_session_pending_seq_idx\` ON \`session_steer\` (\`session_id\`,\`consumed_seq\`,\`seq\`);`,
+ )
+ })
+ },
+} satisfies DatabaseMigration.Migration
diff --git a/packages/core/src/deepagent/agent-push-policy.ts b/packages/core/src/deepagent/agent-push-policy.ts
new file mode 100644
index 00000000..f1608ae9
--- /dev/null
+++ b/packages/core/src/deepagent/agent-push-policy.ts
@@ -0,0 +1,111 @@
+export * as AgentPushPolicy from "./agent-push-policy"
+
+import { DeepAgentEvent } from "./deepagent-event"
+import { ContentSafety } from "./content-safety"
+import { QuietHours } from "./quiet-hours"
+
+// V4.0 §B2 — the Agent Push Policy gate. An agent's PROACTIVE outbound message (not a reply to a human
+// turn) must clear this gate before it lands in `im_messages`. This is PURE: the caller resolves the
+// facts (is the agent a group member / does it hold workspace push permission, how many pushes this
+// agent→group already did this hour, is it within quiet hours) and this function decides the outcome.
+// Composes the §E primitives (ContentSafety §E3, QuietHours §E4) with the §B2 permission + rate rules.
+//
+// LAYERING: `core`. No Effect/DB — the deepagent-code wiring resolves membership + the rate count (from
+// im_agent_push_logs) + quiet-hours window and calls decide(); it then persists the (scrubbed) message
+// and appends a push-log row. Gated by the v4AgentPushEnabled flag upstream (a disabled flag never
+// reaches here).
+
+// §B2 the push request contract (mirrors docs §B2 AgentPushRequest).
+export interface AgentPushRequest {
+ readonly workspaceID: string
+ readonly groupID: string
+ readonly agentID: string
+ readonly reason: string
+ readonly priority: DeepAgentEvent.EventPriority
+ readonly content: string
+ readonly idempotencyKey: string
+}
+
+// §B2 default rate ceiling — per agent per group. Lenient per the standing "don't over-restrict"
+// constraint; deployments tighten it.
+export const DEFAULT_PUSH_LIMIT_PER_HOUR = 20
+export const PUSH_WINDOW_MS = 3_600_000
+
+// The resolved facts the gate needs (the caller looks these up).
+export interface PushFacts {
+ // §B2 权限: the agent is a member of the target group OR holds workspace push permission.
+ readonly isGroupMember: boolean
+ readonly hasWorkspacePushPermission: boolean
+ // §B2 限流: how many pushes this (agent, group) already sent in the current window.
+ readonly pushesThisWindow: number
+ // §B2 静默时段: is the target workspace currently within quiet hours?
+ readonly withinQuietHours: boolean
+ // optional overrides.
+ readonly pushLimitPerHour?: number
+ // content-safety config: allowed external-link hosts + max content length.
+ readonly allowedLinkHosts?: ReadonlyArray
+ readonly maxContentChars?: number
+ // §E3 文件路径权限 — the workspace's allowed FS roots for the path ACL. UNDEFINED ⇒ the path leg is a
+ // no-op (backward compatible). An explicit (possibly empty) array turns it ON: file-path tokens in the
+ // push content resolving OUTSIDE every root are stripped («path removed») — an empty array is
+ // fail-closed (strips every detected path). The agent-push runtime resolves this from the workspace
+ // directory / project roots so an agent can never leak an out-of-workspace path in a proactive push.
+ readonly allowedPathRoots?: ReadonlyArray
+}
+
+export type PushDecision =
+ // deliver now — `content` is the SCRUBBED content to persist; `requiresReason` (quiet-hours
+ // high/critical passthrough) means the caller MUST record `reason` on the message/log.
+ | { readonly type: "deliver"; readonly content: string; readonly requiresReason: boolean; readonly promptInjectionSuspected: boolean }
+ // hold for the quiet-hours digest (normal/low during quiet hours) — the scrubbed content is carried
+ // so the digest builder can batch it.
+ | { readonly type: "digest"; readonly content: string; readonly promptInjectionSuspected: boolean }
+ // rejected — fail-closed. `reason` is the machine code; carries the failing check.
+ | { readonly type: "blocked"; readonly reason: PushBlockReason }
+
+export type PushBlockReason = "not_authorized" | "rate_limited"
+
+/**
+ * §B2 — decide the fate of a proactive agent push. Order (fail-closed first):
+ * 1. 权限 → not_authorized unless group member OR workspace push permission.
+ * 2. 限流 → rate_limited when pushesThisWindow >= limit.
+ * 3. 内容安全 → scrub content (redact secrets / strip off-allowlist links / strip out-of-ACL file
+ * paths / truncate); the injection flag is CARRIED to the caller (a flag, not a hard
+ * block, per §E3), never silently sent.
+ * 4. 静默时段 → normal/low → digest; high/critical → deliver with requiresReason.
+ * Note: 去重 (idempotencyKey) is enforced at the persistence layer (unique key), not here.
+ */
+export const decide = (request: AgentPushRequest, facts: PushFacts): PushDecision => {
+ // 1. 权限
+ if (!facts.isGroupMember && !facts.hasWorkspacePushPermission) {
+ return { type: "blocked", reason: "not_authorized" }
+ }
+
+ // 2. 限流
+ const limit = facts.pushLimitPerHour ?? DEFAULT_PUSH_LIMIT_PER_HOUR
+ if (facts.pushesThisWindow >= limit) {
+ return { type: "blocked", reason: "rate_limited" }
+ }
+
+ // 3. 内容安全 — scrub before any delivery decision so digest + deliver both carry clean content.
+ const scrubbed = ContentSafety.scrub({
+ content: request.content,
+ ...(facts.allowedLinkHosts != null ? { allowedLinkHosts: facts.allowedLinkHosts } : {}),
+ ...(facts.maxContentChars != null ? { maxLogChars: facts.maxContentChars } : {}),
+ // §E3 文件路径权限 — enable the path ACL leg when the caller resolved the workspace roots (undefined
+ // ⇒ no-op, preserving the pure-policy tests that pass no roots).
+ ...(facts.allowedPathRoots != null ? { allowedPathRoots: facts.allowedPathRoots } : {}),
+ })
+
+ // 4. 静默时段
+ const quiet = QuietHours.decide({ priority: request.priority, withinQuietHours: facts.withinQuietHours })
+ if (quiet.action === "digest") {
+ return { type: "digest", content: scrubbed.content, promptInjectionSuspected: scrubbed.promptInjectionSuspected }
+ }
+ return {
+ type: "deliver",
+ content: scrubbed.content,
+ requiresReason: "requiresReason" in quiet ? quiet.requiresReason === true : false,
+ promptInjectionSuspected: scrubbed.promptInjectionSuspected,
+ }
+}
diff --git a/packages/core/src/deepagent/approval-queue-sql.ts b/packages/core/src/deepagent/approval-queue-sql.ts
new file mode 100644
index 00000000..d1366d8b
--- /dev/null
+++ b/packages/core/src/deepagent/approval-queue-sql.ts
@@ -0,0 +1,36 @@
+import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core"
+
+// V4.0 §D2 — durable Approval Queue. The human-facing sink for events that need a decision: a Goal that
+// escalated (goal.needs_human), a rollback (goal.rolled_back), or a Panel verdict of needs_human
+// (§M/§N → §D2). One row per queued item; a human resolves it in the Oversight Dashboard (approve /
+// reject / acknowledge). Kept as its own store (not folded into the event log) so the queue's
+// resolution state — pending → resolved, who, when, decision — is mutable and queryable independent of
+// the immutable domain-event that seeded it.
+export const ApprovalQueueTable = sqliteTable(
+ "deepagent_approval_queue",
+ {
+ id: text().primaryKey(),
+ workspace_id: text().notNull(),
+ // the domain event that raised this item (goal.needs_human / goal.rolled_back / panel.verdict).
+ event_id: text().notNull(),
+ event_type: text().notNull(),
+ // correlationID of the raising event — links the queue item to its §F2 trace spine.
+ correlation_id: text(),
+ // a short human-facing summary of what needs approval (rendered from the event payload).
+ summary: text().notNull(),
+ // pending → resolved. A resolved item carries the decision + who + when.
+ status: text().$type<"pending" | "resolved">().notNull(),
+ decision: text().$type<"approved" | "rejected" | "acknowledged">(),
+ resolved_by: text(),
+ resolved_at: integer(),
+ created_at: integer().notNull(),
+ },
+ (table) => [
+ // §D2 去重: one queue item per raising event (a re-delivered event doesn't double-queue).
+ uniqueIndex("deepagent_approval_queue_event_idx").on(table.event_id),
+ // Dashboard: a workspace's pending items, newest first.
+ index("deepagent_approval_queue_pending_idx").on(table.workspace_id, table.status, table.created_at),
+ ],
+)
+
+export * as ApprovalQueueSql from "./approval-queue-sql"
diff --git a/packages/core/src/deepagent/approval-queue.ts b/packages/core/src/deepagent/approval-queue.ts
new file mode 100644
index 00000000..7c2af281
--- /dev/null
+++ b/packages/core/src/deepagent/approval-queue.ts
@@ -0,0 +1,214 @@
+export * as ApprovalQueue from "./approval-queue"
+
+import { Context, Effect, Layer } from "effect"
+import { and, desc, eq } from "drizzle-orm"
+import { Database } from "../database/database"
+import { ApprovalQueueTable } from "./approval-queue-sql"
+import { DeepAgentEvent } from "./deepagent-event"
+import { LMNEvents } from "./lmn-events"
+import { Identifier } from "../util/identifier"
+
+// V4.0 §D2 — the Approval Queue service. The durable sink the Oversight Dashboard reads: escalating
+// events (goal.needs_human / goal.rolled_back / panel.verdict[needs_human]) enqueue here for a human
+// decision. `offer` folds the §M/§N `shouldQueueForApproval` gate so only genuinely-escalating events
+// queue (a panel verdict that resolved autonomously never lands here). `resolve` records the human's
+// decision. UNIQUE(event_id) makes offer idempotent — a re-delivered event never double-queues.
+//
+// LAYERING: `core`. Pure durable state; the bus wiring (deepagent-code) subscribes and calls `offer`,
+// the HTTP/Oversight layer calls `list`/`resolve`.
+
+export interface ApprovalItem {
+ readonly id: string
+ readonly workspaceID: string
+ readonly eventID: string
+ readonly eventType: string
+ readonly correlationID?: string
+ readonly summary: string
+ readonly status: "pending" | "resolved"
+ readonly decision?: "approved" | "rejected" | "acknowledged"
+ readonly resolvedBy?: string
+ readonly resolvedAt?: number
+ readonly createdAt: number
+}
+
+export interface Interface {
+ /**
+ * §D2 — offer an event to the queue. Enqueues IFF `shouldQueueForApproval` says it escalates (folds
+ * the PANEL_VERDICT needs_human payload gate). Idempotent via UNIQUE(event_id). Returns the queued
+ * item, or null if the event does not require approval (or was already queued).
+ */
+ readonly offer: (event: DeepAgentEvent.Event) => Effect.Effect
+ /** §D2 — a workspace's pending items, newest first (the Dashboard view). */
+ readonly listPending: (workspaceID: string) => Effect.Effect>
+ /**
+ * §D2 — a human resolves a pending item. REQUIRES `workspaceID`: the resolve is scoped to it so a
+ * caller can never resolve (write) or read back an item belonging to a DIFFERENT workspace by id
+ * (tenant isolation). Returns null if no PENDING item with that id exists IN THAT workspace. Idempotent:
+ * an already-resolved item is unchanged and its row is returned.
+ */
+ readonly resolve: (input: {
+ readonly id: string
+ readonly workspaceID: string
+ readonly decision: "approved" | "rejected" | "acknowledged"
+ readonly resolvedBy: string
+ }) => Effect.Effect
+}
+
+export class Service extends Context.Service()("@deepagent-code/ApprovalQueue") {}
+
+// §D2 CANONICAL WORKSPACE KEY. The Approval Queue is keyed by a single `workspace_id` string, so the
+// PRODUCE side (goal-manager.emitGoalLifecycleEvent, multi-agent-runtime.escalateForHuman — via the
+// published event's workspaceID) and the READ side (oversight.listPending / resolve) MUST derive that
+// string identically, or an enqueued item is written under one key and queried under another → invisible
+// on the Dashboard. Both sides previously hand-wrote `workspaceID ?? directory` chains that could silently
+// drift. This one function is the shared source of truth for the rule:
+//
+// 1. a GENUINE workspace id (wrk_…) wins — it is the stable, relocation-independent tenant key;
+// 2. else the filesystem directory (single-user / directory-routed model);
+// 3. else a non-wrk workspaceID (which "doubles as a directory" in the directory-routed model);
+// 4. else the caller-supplied fallback (e.g. the sessionID) so a key is always produced.
+//
+// For the typed callers `workspaceID` is `WorkspaceV2.ID | undefined` (schema-checked to start with
+// "wrk"), so steps 1–2 already cover them; step 3 defends the untyped/raw-event path where a directory
+// string may have been carried in the workspaceID field.
+export const deriveWorkspaceKey = (input: {
+ readonly workspaceID?: string | null
+ readonly directory?: string | null
+ readonly fallback?: string
+}): string => {
+ const { workspaceID, directory, fallback } = input
+ if (workspaceID && workspaceID.startsWith("wrk")) return workspaceID
+ if (directory && directory.length > 0) return directory
+ if (workspaceID && workspaceID.length > 0) return workspaceID
+ return fallback ?? ""
+}
+
+export interface LayerOptions {
+ readonly now?: () => number
+}
+
+// a short human-facing summary from the raising event.
+const summarize = (event: DeepAgentEvent.Event): string => {
+ const p = (event.payload ?? {}) as Record
+ switch (event.type) {
+ case LMNEvents.GOAL_NEEDS_HUMAN:
+ return `Goal escalated for human review${p.goalId ? ` (${String(p.goalId)})` : ""}`
+ case LMNEvents.GOAL_ROLLED_BACK:
+ return `Goal rolled back${p.reason ? `: ${String(p.reason)}` : ""}`
+ case LMNEvents.PANEL_VERDICT:
+ return `Expert panel needs human decision${p.question ? `: ${String(p.question)}` : ""}`
+ case LMNEvents.AGENT_TASK_NEEDS_HUMAN:
+ return `Agent task needs human approval${p.reason ? ` (${String(p.reason)})` : ""}${p.intent ? `: ${String(p.intent)}` : ""}`
+ default:
+ return `${event.type} requires approval`
+ }
+}
+
+const decode = (row: {
+ id: string
+ workspace_id: string
+ event_id: string
+ event_type: string
+ correlation_id: string | null
+ summary: string
+ status: string
+ decision: string | null
+ resolved_by: string | null
+ resolved_at: number | null
+ created_at: number
+}): ApprovalItem => ({
+ id: row.id,
+ workspaceID: row.workspace_id,
+ eventID: row.event_id,
+ eventType: row.event_type,
+ ...(row.correlation_id != null ? { correlationID: row.correlation_id } : {}),
+ summary: row.summary,
+ status: row.status as ApprovalItem["status"],
+ ...(row.decision != null ? { decision: row.decision as ApprovalItem["decision"] } : {}),
+ ...(row.resolved_by != null ? { resolvedBy: row.resolved_by } : {}),
+ ...(row.resolved_at != null ? { resolvedAt: row.resolved_at } : {}),
+ createdAt: row.created_at,
+})
+
+export const layerWith = (options?: LayerOptions) =>
+ Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const { db } = yield* Database.Service
+ const now = options?.now ?? Date.now
+
+ const offer: Interface["offer"] = (event) =>
+ Effect.gen(function* () {
+ // §M/§N gate: only genuinely-escalating events queue (folds the PANEL_VERDICT payload check).
+ if (!LMNEvents.shouldQueueForApproval(event)) return null
+
+ const at = now()
+ const item = {
+ id: "apq_" + Identifier.ascending(),
+ workspace_id: event.workspaceID,
+ event_id: event.id,
+ event_type: event.type,
+ correlation_id: event.correlationID ?? null,
+ summary: summarize(event),
+ status: "pending" as const,
+ decision: null,
+ resolved_by: null,
+ resolved_at: null,
+ created_at: at,
+ }
+ // idempotent enqueue: UNIQUE(event_id) means a re-delivered event doesn't double-queue.
+ yield* db.insert(ApprovalQueueTable).values([item]).onConflictDoNothing().run().pipe(Effect.orDie)
+ // return the authoritative row (the winner if we raced a duplicate).
+ const row = yield* db
+ .select()
+ .from(ApprovalQueueTable)
+ .where(eq(ApprovalQueueTable.event_id, event.id))
+ .get()
+ .pipe(Effect.orDie)
+ return row ? decode(row) : null
+ })
+
+ const listPending: Interface["listPending"] = (workspaceID) =>
+ db
+ .select()
+ .from(ApprovalQueueTable)
+ .where(and(eq(ApprovalQueueTable.workspace_id, workspaceID), eq(ApprovalQueueTable.status, "pending")))
+ .orderBy(desc(ApprovalQueueTable.created_at))
+ .all()
+ .pipe(Effect.orDie, Effect.map((rows) => rows.map(decode)))
+
+ const resolve: Interface["resolve"] = (input) =>
+ Effect.gen(function* () {
+ const at = now()
+ // only a PENDING item IN THIS WORKSPACE transitions — the workspace_id predicate prevents a
+ // cross-tenant write (resolving another workspace's item by id). Already-resolved = no-op.
+ yield* db
+ .update(ApprovalQueueTable)
+ .set({ status: "resolved", decision: input.decision, resolved_by: input.resolvedBy, resolved_at: at })
+ .where(
+ and(
+ eq(ApprovalQueueTable.id, input.id),
+ eq(ApprovalQueueTable.workspace_id, input.workspaceID),
+ eq(ApprovalQueueTable.status, "pending"),
+ ),
+ )
+ .run()
+ .pipe(Effect.orDie)
+ // re-select is ALSO workspace-scoped: a row belonging to another workspace is never returned
+ // (no cross-tenant read-back), so an unknown/foreign id yields null.
+ const row = yield* db
+ .select()
+ .from(ApprovalQueueTable)
+ .where(and(eq(ApprovalQueueTable.id, input.id), eq(ApprovalQueueTable.workspace_id, input.workspaceID)))
+ .get()
+ .pipe(Effect.orDie)
+ return row ? decode(row) : null
+ })
+
+ return Service.of({ offer, listPending, resolve })
+ }),
+ )
+
+export const layer = layerWith()
+
+export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
diff --git a/packages/core/src/deepagent/autonomy-policy.ts b/packages/core/src/deepagent/autonomy-policy.ts
new file mode 100644
index 00000000..ecea536c
--- /dev/null
+++ b/packages/core/src/deepagent/autonomy-policy.ts
@@ -0,0 +1,106 @@
+export * as AutonomyPolicy from "./autonomy-policy"
+
+import { AutonomyLevel, DEFAULT_AUTONOMY_LEVEL } from "../im/mention-parser"
+import type { AgentDescriptor } from "../im/mention-parser"
+
+// V4.0 §D1 — the autonomy-level POLICY. This is a PURE, deterministic decision function: given an
+// agent's configured autonomy ceiling and the autonomy level an action REQUIRES to execute, it decides
+// whether the action is allowed and — when allowed — which Human Gate must be enforced before/after the
+// action runs. It reads NOTHING at runtime; no Effect, no DB, no IO. The wiring in deepagent-code
+// resolves the agent descriptor + the action's required level, calls `decide`, and enforces the gate.
+//
+// LAYERING: lives in `core` and imports only the AutonomyLevel schema literals from mention-parser
+// (§C1/§D). Everything else is derived from the §D1 table below, so this module stays unit-testable.
+//
+// §D1 table (docs/deepagentcore-v4.0.md L294-303), mapped to GATE_FOR_LEVEL:
+// Level 0 — read context / explain / suggest → gate: none
+// Level 1 — read-only diagnostics / tests / format → gate: post_hoc_log (事后日志)
+// Level 2 — low-risk edits / add tests / fix lint → gate: auto_pr_or_digest (自动 PR 或每日摘要)
+// Level 3 — bug fixes / limited code changes → gate: pr_approval (PR 审批)
+// Level 4 — architecture refactor / multi-module → gate: plan_and_pr_approval (方案审批 + PR 审批)
+// Level 5 — tech direction / large deletions → gate: suggestion_only (仅建议)
+//
+// KEY RULE (L303 — "Agent 配置不能把风险等级降级;只能收紧"): an agent's configured autonomy is a
+// CEILING that can only TIGHTEN. An action requiring a level ABOVE the ceiling is refused
+// (exceeds_ceiling); it can NEVER be escalated to run. An agent capable of MORE than an action requires
+// still runs that action under the ACTION's own (lower) gate — capability does not relax the gate.
+//
+// LEVEL 5 CONTRACT ("仅建议 / suggestion_only"): a level_5 action is NEVER auto-executed. When the
+// ceiling is below level_5, a level_5 action is refused (exceeds_ceiling). When the ceiling IS level_5,
+// decide() returns `{ allowed: true, gate: "suggestion_only" }` — but "allowed" here means "the agent may
+// PRODUCE A SUGGESTION". The CALLER MUST treat a `suggestion_only` gate as "emit a suggestion, do not
+// execute the action". No path in this module ever green-lights auto-execution of a level_5 action.
+
+// Ordinal rank for each level, for ordering comparisons. level_0=0 … level_5=5.
+export const LEVEL_RANK: Record = {
+ level_0: 0,
+ level_1: 1,
+ level_2: 2,
+ level_3: 3,
+ level_4: 4,
+ level_5: 5,
+}
+
+// The KIND of Human Gate a level demands per the §D1 table. One literal per row.
+export type HumanGate =
+ | "none"
+ | "post_hoc_log"
+ | "auto_pr_or_digest"
+ | "pr_approval"
+ | "plan_and_pr_approval"
+ | "suggestion_only"
+
+// §D1 mapping: each autonomy level → the Human Gate that must be enforced for an action at that level.
+export const GATE_FOR_LEVEL: Record = {
+ level_0: "none",
+ level_1: "post_hoc_log",
+ level_2: "auto_pr_or_digest",
+ level_3: "pr_approval",
+ level_4: "plan_and_pr_approval",
+ level_5: "suggestion_only",
+}
+
+// The autonomy level an ACTION requires to execute — i.e. the action "needs at least level_N". Reuses
+// the AutonomyLevel literals: an action's risk IS the minimum level a capable agent must be at.
+export type ActionRisk = AutonomyLevel
+
+export type AutonomyDecision =
+ // action within the ceiling → allowed; `gate` is the ACTION's own gate to enforce (see decide).
+ | { readonly allowed: true; readonly gate: HumanGate }
+ // action requires more autonomy than the agent's ceiling → refused; carries both levels for the trace.
+ | {
+ readonly allowed: false
+ readonly reason: "exceeds_ceiling"
+ readonly ceiling: AutonomyLevel
+ readonly required: AutonomyLevel
+ }
+
+/**
+ * §D1 — the pure autonomy decision.
+ *
+ * - If the action requires a rank ABOVE the agent's ceiling → NOT allowed (`exceeds_ceiling`). This
+ * enforces "config can only tighten": a level_2-capped agent can never perform a level_3 action.
+ * - Otherwise allowed, and `gate` = the gate of the ACTION's required level (GATE_FOR_LEVEL[actionRequires]),
+ * NOT the ceiling's gate — a level_4 agent doing a level_2 edit still only owes the level_2 gate.
+ * - level_5 ("仅建议 / suggestion_only"): a level_5 action is never auto-executed. If ceiling < 5 it is
+ * refused; if ceiling IS level_5 it returns `{ allowed: true, gate: "suggestion_only" }`, which the
+ * CALLER must treat as "produce a suggestion, do not execute". `allowed:true` here == "may suggest".
+ */
+export const decide = (input: {
+ readonly agentCeiling: AutonomyLevel
+ readonly actionRequires: AutonomyLevel
+}): AutonomyDecision => {
+ const ceiling = input.agentCeiling
+ const required = input.actionRequires
+
+ if (LEVEL_RANK[required] > LEVEL_RANK[ceiling]) {
+ return { allowed: false, reason: "exceeds_ceiling", ceiling, required }
+ }
+
+ return { allowed: true, gate: GATE_FOR_LEVEL[required] }
+}
+
+// The agent's autonomy ceiling, defaulting to the conservative DEFAULT_AUTONOMY_LEVEL (level_0 — fully
+// manual) when unset. Keeps the default resolution in one place.
+export const resolveCeiling = (descriptor: Pick): AutonomyLevel =>
+ descriptor.autonomy ?? DEFAULT_AUTONOMY_LEVEL
diff --git a/packages/core/src/deepagent/background-learning.ts b/packages/core/src/deepagent/background-learning.ts
index 31b4ef28..326baf69 100644
--- a/packages/core/src/deepagent/background-learning.ts
+++ b/packages/core/src/deepagent/background-learning.ts
@@ -8,6 +8,7 @@ import type { ProjectPaths } from "./workspace"
import { DurableKnowledgeStore, openProjectStore, type KnowledgeDocInput } from "./durable-knowledge-store"
import type { DocType, EvidenceStrength } from "./document-store"
import { evidenceFromConfidence } from "./knowledge-retriever"
+import { fingerprint as candidateFingerprint, type RejectedBuffer } from "./promotion"
import * as Governance from "./memory-governance"
export const MEMORY_INBOX_SCHEMA_VERSION = "deepagent-code.memory_inbox_item.v1"
@@ -75,6 +76,11 @@ export class LearningWorker {
private readonly paths: ProjectPaths,
private readonly projectID = readProjectID(paths),
store?: DurableKnowledgeStore,
+ // The durable RejectedBuffer (the human-`reject` fingerprint cache). Gate 3 (R3 anti-pollution)
+ // consults it so a pattern a human explicitly rejected is NOT silently re-learned + auto-admitted on
+ // a later run. Injected by the caller that owns the memory-dir path (the reject handler writes the
+ // SAME buffer). Omitted ⇒ gate 3 is inert (back-compat for tests / callers without a buffer).
+ private readonly rejectedBuffer?: RejectedBuffer,
) {
// ProjectPaths.root is /project/; the durable knowledge store roots at
// /project//knowledge — i.e. a "knowledge" subdir of the project root.
@@ -105,9 +111,13 @@ export class LearningWorker {
const contradictsHighTrust = this.detectHighTrustContradiction(candidate, classification)
const govRoute = Governance.route({
classification,
- // RejectedBuffer is consulted upstream (promote path); learning candidates carry status
- // "rejected" when extraction already rejected them.
- inRejectedBuffer: candidate.status === "rejected",
+ // Gate 3 (R3): consult the durable RejectedBuffer by fingerprint so a pattern a human explicitly
+ // rejected is dropped, not silently re-learned + auto-admitted. Extraction always emits fresh
+ // candidates as status "staged" (never "rejected"), so the old `status === "rejected"` check was
+ // ALWAYS false — the gate was vacuous and the buffer was never consulted on this path. When no
+ // buffer is injected the gate stays inert (back-compat), matching the prior effective behavior.
+ inRejectedBuffer:
+ (this.rejectedBuffer?.has(candidateFingerprint(candidate)) ?? false) || candidate.status === "rejected",
contradictsHighTrust,
// Learning candidates never self-promote into a pack or to global scope; those are explicit
// human actions (gate 6/7) handled in the review/promote path, so false here.
diff --git a/packages/core/src/deepagent/code-indexer.ts b/packages/core/src/deepagent/code-indexer.ts
index 12ec72db..78053e8d 100644
--- a/packages/core/src/deepagent/code-indexer.ts
+++ b/packages/core/src/deepagent/code-indexer.ts
@@ -1,4 +1,5 @@
import { createHash } from "node:crypto"
+import { Effect } from "effect"
import type { DurableKnowledgeStore } from "./durable-knowledge-store"
import type { Doc, DocType, LinkRel, Provenance } from "./document-store"
@@ -94,6 +95,25 @@ const findByPath = (store: DurableKnowledgeStore, path: string): Doc | null => {
return null
}
+// The stored `mtime_ms` for every already-indexed code_symbol node, keyed by path. Exposed for a
+// filesystem-walking caller (code-index-trigger) to skip the READ + HASH of a file whose on-disk mtime
+// EXACTLY equals the recorded one — the only layer where mtime avoids real I/O (§B.3 SEAM). This is an
+// I/O-avoidance hint, NOT a correctness gate: an EXACT match is required (not `>=`), so a rewound or
+// non-monotonic mtime (git checkout / stash pop / rebase) does NOT equal the stored value and the file
+// is re-read + content-sha-gated as before. Content-sha remains the sole skip AUTHORITY for anything
+// actually read. A node without a recorded mtime is simply absent from the map (⇒ always re-read).
+export const indexedMtimes = (store: DurableKnowledgeStore): ReadonlyMap => {
+ const ds = store.documentStore
+ const out = new Map()
+ for (const ref of ds.list({ type: CODE_SYMBOL })) {
+ const doc = ds.get(ref.id)
+ if (!doc || doc.status === "rejected") continue
+ const mtime = doc.extensions?.mtime_ms
+ if (typeof mtime === "number") out.set(doc.description, mtime)
+ }
+ return out
+}
+
// Register (create or content-gated upsert) a single file as a code_symbol node. Returns the node id
// plus whether it was created/updated/unchanged. CONTENT-SHA gating avoids version bloat.
export const registerFile = (
@@ -538,3 +558,33 @@ export const linkCallEdges = (
}
return { callsEdges, callsSkipped }
}
+
+// V4.0 §C3.3 — resolve the FULLY-QUALIFIED symbol keys ("#") of the symbol
+// nodes hosted by a set of files, from an already-open project store. This is the code-graph feed for
+// the ConflictArbiter's semantic layer: two subtasks touching the same symbol conflict, and qualifying
+// the key by host_path means the SAME symbol name in DIFFERENT files does NOT false-conflict. It scans
+// the store's code_symbol nodes and collects the symbol children whose `extensions.host_path` matches a
+// requested file (the file-level parent node carries no symbol_path, so it is naturally excluded).
+// PURE over the store + no filesystem; default-safe — a missing/empty graph yields []. Wrapped in Effect
+// so the runtime's resolver can catch any store defect and fall back to file-level detection.
+export const symbolsForFilePaths = (
+ store: DurableKnowledgeStore,
+ files: ReadonlyArray,
+): Effect.Effect> =>
+ Effect.sync(() => {
+ if (files.length === 0) return []
+ const wanted = new Set(files)
+ const ds = store.documentStore
+ const keys: string[] = []
+ for (const ref of ds.list({ type: CODE_SYMBOL })) {
+ const doc = ds.get(ref.id)
+ if (!doc || doc.status === "rejected") continue
+ const hostPath = doc.extensions?.host_path
+ const symbolPath = doc.extensions?.symbol_path
+ // only symbol CHILD nodes carry host_path + symbol_path; file-level parents have neither.
+ if (typeof hostPath !== "string" || typeof symbolPath !== "string") continue
+ if (!wanted.has(hostPath)) continue
+ keys.push(symbolNodeKey(hostPath, symbolPath))
+ }
+ return keys
+ })
diff --git a/packages/core/src/deepagent/command-intent.ts b/packages/core/src/deepagent/command-intent.ts
index aaee1070..1db3b3b6 100644
--- a/packages/core/src/deepagent/command-intent.ts
+++ b/packages/core/src/deepagent/command-intent.ts
@@ -224,8 +224,10 @@ const isReadOnlySegment = (segment: string): boolean => {
if (trimmed === "") return true // empty segment (e.g. trailing operator) is inert
// Any output redirection in the segment → mutating. Strip fd-duplication (2>&1, 1>&2, >&2) first,
- // which is not a file write, then look for a real `>`.
- const withoutFdDup = trimmed.replace(/\d*>&\d*/g, " ")
+ // which is not a file write, then look for a real `>`. Only a NUMERIC right-hand side is an fd-dup;
+ // `>&file`/`>&out.txt` (word RHS) is bash shorthand for redirecting both stdout+stderr to a FILE, so
+ // it must NOT be stripped — its `>` has to survive the write-check below.
+ const withoutFdDup = trimmed.replace(/\d*>&\d/g, " ")
if (withoutFdDup.includes(">")) return false
const tokens = tokenize(trimmed)
@@ -255,7 +257,12 @@ const isReadOnlySegment = (segment: string): boolean => {
const rest = words.slice(1).filter((token) => !isFlag(token))
if (rest.length > 0) return false
}
- if (head === "curl" && /\s-[oO]\b|--output\b|--remote-name\b/.test(trimmed)) return false
+ // curl writes a file with -o/-O/--output/--remote-name. A short-flag TOKEN can glue or bundle the
+ // output flag (`-ofile.txt`, `-sofile`), and since curl short flags are single-letter and `o`/`O`
+ // are only ever the output flags, any single-dash bundle containing `o`/`O` writes a file. Match a
+ // `-…o`/`-…O` short bundle (not a `--long` option, which is covered by the explicit long forms).
+ if (head === "curl" && (/(?:^|\s)-[a-zA-Z]*[oO]/.test(trimmed) || /--output\b|--remote-name\b/.test(trimmed)))
+ return false
return true
}
@@ -274,7 +281,7 @@ export const classifyCommand = (command: string): CommandIntent => {
// exactly per-segment; the placeholder holds no separator or `>` char so it survives the split,
// and isReadOnlySegment strips fd-dup again defensively.
const fdSpans: string[] = []
- const masked = command.replace(/\d*>&\d*/g, (m) => {
+ const masked = command.replace(/\d*>&\d/g, (m) => {
const token = " fd" + fdSpans.length + "fd "
fdSpans.push(m)
return token
diff --git a/packages/core/src/deepagent/conflict-arbiter.ts b/packages/core/src/deepagent/conflict-arbiter.ts
new file mode 100644
index 00000000..198dc0bc
--- /dev/null
+++ b/packages/core/src/deepagent/conflict-arbiter.ts
@@ -0,0 +1,102 @@
+export * as ConflictArbiter from "./conflict-arbiter"
+
+import { DeepAgentEvent } from "./deepagent-event"
+
+// V4.0 §C3 — the Conflict Arbiter. PURE conflict DETECTION + resolution ORDERING for multiple agents
+// editing concurrently. It does not hold locks or touch git — the runtime (deepagent-code) enforces the
+// physical isolation (FileLockService for §C3.1 file locks, branch/worktree for §C3.2). This module
+// decides (a) WHETHER two claims conflict, and (b) given a conflict set, which claim WINS the ordering
+// (§C3 处理顺序). Kept pure so the arbitration is deterministic + unit-testable.
+//
+// §C3 three isolation layers (this module models the DECISION for each; enforcement is the runtime's):
+// 1. 文件锁 — two claims conflict if their file scopes overlap (a claim with empty scope = broad,
+// conservatively conflicts with everything).
+// 2. 分支隔离 — each agent works on its own branch/worktree (runtime concern; not decided here).
+// 3. 语义冲突 — two claims conflict if they touch the same symbol (caller supplies symbol sets from
+// the code graph; empty symbols ⇒ fall back to file-scope overlap only).
+//
+// §C3 处理顺序 (resolution ordering, applied by `rank`/`resolve`):
+// 1. critical/high 优先 (higher priority wins).
+// 2. 更小 diff 优先 (smaller declared change wins).
+// 3. 人类显式任务优先于周期任务 (human-originated beats scheduled/system).
+// 4. 无法自动合并 → human approval queue (resolve returns needsHuman when a winner can't be picked
+// deterministically, i.e. a true tie on all keys).
+
+// A claim = one agent's intent to modify a set of files/symbols, carrying the info the ordering needs.
+export interface Claim {
+ readonly taskID: string
+ readonly agentID: string
+ readonly files: ReadonlyArray
+ // symbols (from the code graph) this claim modifies; empty ⇒ semantic layer not evaluated for it.
+ readonly symbols: ReadonlyArray
+ readonly priority: DeepAgentEvent.EventPriority
+ // declared size of the change (e.g. lines or files touched) — smaller wins per §C3.2. Optional;
+ // undefined ⇒ treated as unknown/large (loses the diff-size tiebreak).
+ readonly diffSize?: number
+ // §C3.3: a claim originating from a human's explicit task beats a periodic/scheduled one.
+ readonly origin: "human" | "schedule" | "system"
+}
+
+const PRIORITY_RANK: Record = {
+ low: 0,
+ normal: 1,
+ high: 2,
+ critical: 3,
+}
+
+const overlaps = (a: ReadonlyArray, b: ReadonlyArray): boolean => {
+ const set = new Set(a)
+ return b.some((x) => set.has(x))
+}
+
+/**
+ * §C3 — do two claims conflict? A claim with an EMPTY file scope is "broad/unknown" and conservatively
+ * conflicts with any other claim (fail-safe: the runtime must serialize it). Otherwise claims conflict
+ * if their file scopes overlap OR (when both declare symbols) their symbol sets overlap.
+ */
+export const conflicts = (a: Claim, b: Claim): boolean => {
+ if (a.taskID === b.taskID) return false
+ if (a.files.length === 0 || b.files.length === 0) return true // broad scope ⇒ conservative conflict
+ if (overlaps(a.files, b.files)) return true
+ if (a.symbols.length > 0 && b.symbols.length > 0 && overlaps(a.symbols, b.symbols)) return true
+ return false
+}
+
+
+// §C3 ordering comparator: negative ⇒ `a` wins (sorts first). Applies the four keys in order:
+// priority desc → diffSize asc → origin(human first) → stable by taskID.
+const ORIGIN_RANK: Record = { human: 0, schedule: 1, system: 1 }
+export const compare = (a: Claim, b: Claim): number => {
+ const p = PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority] // higher priority first
+ if (p !== 0) return p
+ const da = a.diffSize ?? Number.POSITIVE_INFINITY
+ const db = b.diffSize ?? Number.POSITIVE_INFINITY
+ if (da !== db) return da - db // smaller diff first
+ const o = ORIGIN_RANK[a.origin] - ORIGIN_RANK[b.origin] // human before schedule/system
+ if (o !== 0) return o
+ return a.taskID < b.taskID ? -1 : a.taskID > b.taskID ? 1 : 0 // stable
+}
+
+export type Resolution =
+ | { readonly type: "winner"; readonly winner: Claim; readonly deferred: ReadonlyArray }
+ | { readonly type: "needs_human"; readonly claims: ReadonlyArray }
+
+/**
+ * §C3 — resolve ONE conflict group into a single winner (proceeds now) + deferred claims (re-queued
+ * after the winner completes), OR `needs_human` when the top two claims are indistinguishable on every
+ * ordering key (a true tie ⇒ "无法自动合并" → human approval queue). A singleton group trivially wins.
+ */
+export const resolve = (group: ReadonlyArray): Resolution => {
+ if (group.length === 0) return { type: "needs_human", claims: [] }
+ if (group.length === 1) return { type: "winner", winner: group[0], deferred: [] }
+ const sorted = [...group].sort(compare)
+ // a true tie on all deterministic keys EXCEPT the taskID stabilizer ⇒ can't auto-pick → human.
+ const top = sorted[0]
+ const second = sorted[1]
+ const tie =
+ PRIORITY_RANK[top.priority] === PRIORITY_RANK[second.priority] &&
+ (top.diffSize ?? Number.POSITIVE_INFINITY) === (second.diffSize ?? Number.POSITIVE_INFINITY) &&
+ ORIGIN_RANK[top.origin] === ORIGIN_RANK[second.origin]
+ if (tie) return { type: "needs_human", claims: group }
+ return { type: "winner", winner: top, deferred: sorted.slice(1) }
+}
diff --git a/packages/core/src/deepagent/content-safety.ts b/packages/core/src/deepagent/content-safety.ts
new file mode 100644
index 00000000..7f8c63a6
--- /dev/null
+++ b/packages/core/src/deepagent/content-safety.ts
@@ -0,0 +1,163 @@
+export * as ContentSafety from "./content-safety"
+
+import { PathAcl } from "./path-acl"
+
+// V4.0 §E3 — the CONTENT SAFETY scrubber. A PURE, deterministic function that sanitises any text about
+// to leave the trust boundary (an agent-authored push, a log excerpt, an LLM prompt/response). It
+// mirrors the redaction approach of deepagent-code's import/util/secrets.ts but is reimplemented
+// SELF-CONTAINED here because `core` cannot import from deepagent-code.
+//
+// LAYERING: lives in `core`. It imports only the sibling PURE `PathAcl` policy (node `path` only) — no
+// IO, no config store. The caller passes the allowlist, limits, and (optionally) the allowed FS roots
+// in, so this stays a pure, unit-testable policy.
+//
+// §E3 责任, mapped to `scrub`:
+// secret 脱敏 : replace API keys / tokens / bearer / aws keys with «redacted».
+// 文件路径权限 : when `allowedPathRoots` is provided, strip file-path tokens that resolve OUTSIDE
+// the allowed roots (via PathAcl) with «path removed». Omitted ⇒ no-op (the caller
+// resolved paths elsewhere) — backward compatible.
+// 外链白名单 : strip URLs whose host is not in `allowedLinkHosts` (undefined = allow all).
+// 大日志截断 : truncate content beyond `maxLogChars` with a `…[truncated]` marker.
+// 注入风险标记 : FLAG (not modify) content matching common prompt-injection patterns.
+
+const REDACTED = "«redacted»"
+const LINK_REMOVED = "«link removed»"
+const PATH_REMOVED = "«path removed»"
+const TRUNCATION_MARKER = "…[truncated]"
+
+// Lenient default log ceiling — large but bounded. Callers tighten per surface.
+const DEFAULT_MAX_LOG_CHARS = 100_000
+
+// §E3 secret 脱敏 — heuristic credential patterns. Mirrors secrets.ts SECRET_PATTERNS. All global so
+// every occurrence is replaced.
+const SECRET_PATTERNS: ReadonlyArray = [
+ /sk-ant-[A-Za-z0-9_\-]{16,}/g, // Anthropic key (before the generic sk- rule)
+ /sk-[A-Za-z0-9_\-]{16,}/g, // OpenAI-style key
+ /Bearer\s+[A-Za-z0-9_\-\.]{16,}/gi, // Bearer token
+ /(?:ANTHROPIC|OPENAI|DEEPSEEK)[A-Z_]*TOKEN\s*[:=]\s*["']?[A-Za-z0-9_\-]{8,}/gi, // env token
+ /gh[pousr]_[A-Za-z0-9]{16,}/g, // GitHub token
+ /AIza[0-9A-Za-z_\-]{20,}/g, // Google API key
+ /AKIA[0-9A-Z]{16}/g, // AWS access key id
+]
+
+// §E3 注入风险标记 — common prompt-injection tells. Case-insensitive; used only to set the flag (not a
+// hard gate). Patterns are deliberately broad on the connective words (the/your/any/all/prior + a
+// bounded `\w+` gap) so obvious variants ("ignore your previous instructions", "ignore all prior
+// instructions") are caught, while the bounded `{0,3}` word gap avoids catastrophic backtracking.
+const INJECTION_PATTERNS: ReadonlyArray = [
+ /ignore\s+(?:\w+\s+){0,3}(?:previous|prior|above|earlier)\s+(?:instructions|prompts?|context)/i,
+ /(?:disregard|forget|override)\s+(?:\w+\s+){0,3}(?:previous|prior|above|earlier|instructions|prompt)/i,
+ /you\s+are\s+now\b/i,
+ /system\s+prompt/i,
+ /new\s+instructions\s*:/i,
+]
+
+// Any http(s) URL. Host is captured to check against the allowlist.
+const URL_PATTERN = /https?:\/\/([^\s/?#]+)[^\s]*/gi
+
+// §E3 文件路径权限 — CONSERVATIVE file-path token detector. Only fires on shapes that look
+// unambiguously like a filesystem path, so ordinary prose (fractions "1/2", "and/or", "3/4") is not
+// mangled. Each alternative is anchored by a `(?=2 segments + a `.ext`, so a bare
+// "a/b" word pair without an extension never matches)
+// Each candidate is then checked against the ACL; only DISALLOWED ones are stripped.
+const PATH_PATTERN =
+ /(?
+ // truncate beyond this many chars. Defaults to a lenient 100_000.
+ readonly maxLogChars?: number
+ // §E3 文件路径权限 — allowed FS roots for the path ACL. UNDEFINED = the path leg is a NO-OP (backward
+ // compatible; the caller resolved paths elsewhere). An explicit (possibly empty) array turns the leg
+ // ON: file-path tokens resolving OUTSIDE every root are replaced with «path removed». An empty array
+ // (allow nothing) strips every detected path.
+ readonly allowedPathRoots?: ReadonlyArray
+}
+
+export interface ScrubResult {
+ readonly content: string
+ readonly redactedSecrets: number
+ readonly strippedLinks: number
+ // §E3 — count of file-path tokens stripped by the ACL. Always 0 when `allowedPathRoots` is undefined.
+ readonly strippedPaths: number
+ readonly truncated: boolean
+ readonly promptInjectionSuspected: boolean
+}
+
+// Extract the bare host (drop any userinfo / port / trailing punctuation) from a URL's authority for
+// allowlist comparison. A trailing dot (FQDN root, or a URL ending a sentence — "see https://ok.com.")
+// is stripped so a whitelisted host isn't over-stripped by punctuation.
+const hostOf = (authority: string): string => {
+ const noUser = authority.includes("@") ? authority.slice(authority.lastIndexOf("@") + 1) : authority
+ const noPort = noUser.split(":")[0] ?? noUser
+ return noPort.replace(/\.+$/, "").toLowerCase()
+}
+
+/**
+ * §E3 — sanitise `content` and report what was changed/flagged. Order:
+ * 1. redact secrets → replace each match with «redacted», counting hits.
+ * 2. strip links → if an allowlist is provided, replace disallowed URLs with «link removed».
+ * 3. strip paths → if `allowedPathRoots` is provided, replace file-path tokens resolving OUTSIDE
+ * the roots with «path removed», counting hits. Omitted ⇒ no-op.
+ * 4. flag injection → set promptInjectionSuspected if any injection pattern matches (no mutation).
+ * 5. truncate → cut beyond maxLogChars, appending `…[truncated]`.
+ * Injection detection runs on the post-redaction/post-strip text and does NOT alter content.
+ */
+export const scrub = (input: ScrubInput): ScrubResult => {
+ let content = input.content
+ let redactedSecrets = 0
+ let strippedLinks = 0
+ let strippedPaths = 0
+
+ // 1. secret 脱敏
+ for (const re of SECRET_PATTERNS) {
+ content = content.replace(re, () => {
+ redactedSecrets++
+ return REDACTED
+ })
+ }
+
+ // 2. 外链白名单 — undefined allowlist = allow all (strip nothing). An explicit list strips others.
+ const allowed = input.allowedLinkHosts
+ if (allowed != null) {
+ const allowedLower = allowed.map((h) => h.toLowerCase())
+ content = content.replace(URL_PATTERN, (match, authority: string) => {
+ if (allowedLower.includes(hostOf(authority))) return match
+ strippedLinks++
+ return LINK_REMOVED
+ })
+ }
+
+ // 3. 文件路径权限 — undefined roots = no-op (backward compatible). An explicit list strips file-path
+ // tokens resolving OUTSIDE every root (PathAcl fail-closed: an empty list strips every detected path).
+ const allowedPathRoots = input.allowedPathRoots
+ if (allowedPathRoots != null) {
+ content = content.replace(PATH_PATTERN, (match) => {
+ if (PathAcl.isPathAllowed(match, allowedPathRoots)) return match
+ strippedPaths++
+ return PATH_REMOVED
+ })
+ }
+
+ // 4. 注入风险标记 — flag only, never mutate.
+ const promptInjectionSuspected = INJECTION_PATTERNS.some((re) => re.test(content))
+
+ // 5. 大日志截断 — cut on CODE-POINT boundaries (Array.from), not UTF-16 units, so truncating at a
+ // boundary that lands mid-surrogate (emoji/astral char) never leaves a lone surrogate in the output.
+ const maxLogChars = input.maxLogChars ?? DEFAULT_MAX_LOG_CHARS
+ let truncated = false
+ const codepoints = Array.from(content)
+ if (codepoints.length > maxLogChars) {
+ content = codepoints.slice(0, maxLogChars).join("") + TRUNCATION_MARKER
+ truncated = true
+ }
+
+ return { content, redactedSecrets, strippedLinks, strippedPaths, truncated, promptInjectionSuspected }
+}
diff --git a/packages/core/src/deepagent/context-admission.ts b/packages/core/src/deepagent/context-admission.ts
index d7abdddb..9e992ef2 100644
--- a/packages/core/src/deepagent/context-admission.ts
+++ b/packages/core/src/deepagent/context-admission.ts
@@ -74,7 +74,10 @@ export const admitIndexRefs = (
for (const e of candidates) {
if (admitted.length >= p.max_index_refs) break
const eTokens = Math.ceil((e.title.length + e.summary.length) / 4)
- if (tokens + eTokens > p.max_estimated_tokens) break
+ // Skip (not break) over-budget refs: candidates are in filter order, not sorted by size,
+ // so a large ref must not starve smaller admittable refs behind it. Budget is only charged
+ // for refs actually admitted, so skipped entries leave `tokens` unchanged.
+ if (tokens + eTokens > p.max_estimated_tokens) continue
admitted.push(e)
tokens += eTokens
}
diff --git a/packages/core/src/deepagent/context/config.ts b/packages/core/src/deepagent/context/config.ts
index fb52faa1..4f9b5b5d 100644
--- a/packages/core/src/deepagent/context/config.ts
+++ b/packages/core/src/deepagent/context/config.ts
@@ -80,11 +80,3 @@ export const resolveContextConfig = (overrides: ContextConfigOverrides = {}): Co
excludeReasoning: merged.excludeReasoning,
}
}
-
-// The absolute token ceiling for a working set given a model context window. This is THE enforcement
-// point referenced by the Curator: budget = floor(contextTokens * budgetFraction), and budgetFraction
-// is already clamped to <= 0.5. Returns 0 for an unknown/zero context (caller then falls back).
-export const workingSetBudgetTokens = (contextTokens: number, config: ContextConfig): number => {
- if (!Number.isFinite(contextTokens) || contextTokens <= 0) return 0
- return Math.floor(contextTokens * config.budgetFraction)
-}
diff --git a/packages/core/src/deepagent/context/curator.ts b/packages/core/src/deepagent/context/curator.ts
deleted file mode 100644
index ac68dc19..00000000
--- a/packages/core/src/deepagent/context/curator.ts
+++ /dev/null
@@ -1,122 +0,0 @@
-import { Context, Effect, Layer } from "effect"
-import type { DocumentStore } from "../document-store"
-import { GraphQuery } from "../graph-query"
-import type { ContextConfig, ContextConfigOverrides } from "./config"
-import { resolveContextConfig } from "./config"
-import type { Ledger } from "./ledger"
-import { loadLedger } from "./ledger"
-import type { WorkingSet, WorkingSetCandidate } from "./working-set"
-import { assemble, anchorCandidates, ledgerRecall } from "./working-set"
-
-// V3.8 Appendix-A C1/C2 (Stage 2) — the Curator service. It assembles the per-turn Working Set from:
-// - the session Ledger (task anchor + recall candidates), loaded from the run-scoped DocumentStore,
-// - relevance recall via the SHARED GraphQuery service (keyword/token, NO embeddings — decision #3),
-// - the caller-supplied near-field verbatim turns + active references,
-// under the HARD 50% ceiling enforced in working-set.assemble.
-//
-// DEFAULT-SAFE (Phase 3 lesson): DocumentStore construction/reads throw SYNCHRONOUSLY (JSON.parse /
-// readFileSync). Effect.catch recovers only typed errors, NOT defects, and catchAllCause is not in
-// this build. So the "never fails into the session loop" guarantee is implemented with
-// Effect.matchCauseEffect, which recovers the CAUSE (defects included). On ANY failure the Curator
-// returns `undefined` (a signal to the caller to fall back to existing compaction) — it never throws.
-
-export type CuratorRequest = {
- // The current task/step text used for relevance scoring (typically the latest user message + the
- // ledger's current `next`).
- readonly task: string
- // Run-scoped DocumentStore holding this session's ledger. Omitted -> anchor/recall come only from
- // an empty ledger (near-field still assembles). The Curator NEVER constructs a store itself.
- readonly store?: DocumentStore
- readonly sessionId: string
- // Model context window in tokens (for the budget). 0/unknown -> Curator returns undefined (fall
- // back), since a budget can't be computed.
- readonly contextTokens: number
- // Verbatim recent turns (already ordered oldest->newest by the caller). Each carries optional real
- // token counts (C5) and an isReasoning flag so reasoning is excluded.
- readonly nearField: readonly WorkingSetCandidate[]
- // Active references: latest version of files/tool outputs the current task touches.
- readonly references?: readonly WorkingSetCandidate[]
- // Workspace path for GraphQuery's project-store union (optional).
- readonly workspacePath?: string
- readonly configOverrides?: ContextConfigOverrides
-}
-
-export interface Interface {
- // Build the Working Set for this turn, or undefined if unconfigured/failed (caller falls back).
- readonly curate: (req: CuratorRequest) => Effect.Effect
-}
-
-export class Service extends Context.Service()("@deepagent-code/deepagent/context/Curator") {}
-
-// GraphQuery recall over the ledger entries: pulls `ledger`-type hits scored by keyword/token
-// similarity + graph distance, mapped to recall candidates. This is the "recall via GraphQuery" path
-// (decision #3 — reuse the shared retrieval service, no parallel recall layer). Falls back to the
-// in-ledger `ledgerRecall` scorer when GraphQuery returns nothing (e.g. knowledge-source unconfigured
-// in a pure unit test), so recall still works over the loaded ledger.
-const buildRecall = (
- graph: GraphQuery.Interface,
- req: CuratorRequest,
- ledger: Ledger,
- config: ContextConfig,
-) =>
- Effect.gen(function* () {
- const result = yield* graph.query({
- ...(req.workspacePath ? { workspacePath: req.workspacePath } : {}),
- task: req.task,
- types: ["ledger"],
- limitPerType: config.recallLimit,
- })
- const anchorIds = new Set(anchorCandidates(ledger).map((c) => c.id))
- const hits = (result.byType["ledger"] ?? [])
- .filter((h) => !anchorIds.has(h.doc.id))
- .map(
- (h): WorkingSetCandidate => ({
- id: h.doc.id,
- kind: "recall",
- text: h.doc.body,
- score: h.score,
- }),
- )
- if (hits.length > 0) return hits
- // Fallback: score the loaded ledger locally (GraphQuery empty -> unconfigured knowledge-source).
- return ledgerRecall(ledger, req.task, config)
- })
-
-const curateImpl = (graph: GraphQuery.Interface) => (req: CuratorRequest) =>
- Effect.gen(function* () {
- const config = resolveContextConfig(req.configOverrides)
- if (req.contextTokens <= 0) return undefined // can't budget -> fall back
-
- // Ledger load throws synchronously on a corrupt store; the whole gen is guarded below.
- const ledger = req.store ? loadLedger(req.store, req.sessionId) : { sessionId: req.sessionId, entries: [], updatedAt: Date.now() }
- const anchor = anchorCandidates(ledger)
- const recall = yield* buildRecall(graph, req, ledger, config)
-
- const nearField = req.nearField.slice(-config.nearFieldTurns)
-
- return assemble({
- contextTokens: req.contextTokens,
- config,
- anchor,
- nearField,
- references: req.references ?? [],
- recall,
- })
- }).pipe(
- // DEFAULT-SAFE: recover the CAUSE (defects from sync store throws included), never rethrow into
- // the session loop. Returns undefined -> caller keeps existing compaction behavior.
- Effect.matchCauseEffect({
- onFailure: () => Effect.succeed(undefined),
- onSuccess: (ws) => Effect.succeed(ws),
- }),
- )
-
-export const layer = Layer.effect(
- Service,
- Effect.gen(function* () {
- const graph = yield* GraphQuery.Service
- return Service.of({ curate: curateImpl(graph) })
- }),
-)
-
-export const defaultLayer = layer.pipe(Layer.provide(GraphQuery.layer))
diff --git a/packages/core/src/deepagent/context/index.ts b/packages/core/src/deepagent/context/index.ts
index 3df9e6c3..fcfe37a4 100644
--- a/packages/core/src/deepagent/context/index.ts
+++ b/packages/core/src/deepagent/context/index.ts
@@ -1,10 +1,10 @@
-// V3.8 Appendix-A — context-management redesign (Working Set + Ledger + Bridge + Conversation Log +
-// Curator + chunked ingest). Greenfield module. See each file for the C-section it implements.
+// V3.8 Appendix-A — context-management substrate. The live members: session Ledger (C2) + Project
+// Bridge (C3, cross-session handoff) + Conversation Log (C2.5) + Config knobs. The main-session context
+// CURATOR bridge (Working Set / Curator / chunked Ingest / a standalone token-meter) was never wired to
+// the main prompt loop — the only live symbol-graph retrieval is the IM @agent path's own
+// context-builder — so that dead cluster was removed in V4.1 T2.5. If main-session code-graph retrieval
+// is wanted later, design it as an explicit new feature rather than reviving this half-wired layer.
export * as ContextConfig from "./config"
-export * as ContextTokenMeter from "./token-meter"
export * as SessionLedger from "./ledger"
-export * as WorkingSet from "./working-set"
-export * as ContextCurator from "./curator"
export * as ConversationLog from "./conversation-log"
export * as ProjectBridge from "./bridge"
-export * as ContextIngest from "./ingest"
diff --git a/packages/core/src/deepagent/context/ingest.ts b/packages/core/src/deepagent/context/ingest.ts
deleted file mode 100644
index a19e7b37..00000000
--- a/packages/core/src/deepagent/context/ingest.ts
+++ /dev/null
@@ -1,249 +0,0 @@
-import { Effect } from "effect"
-import { LLM, Message, type Model, type LLMClientService } from "@deepagent-code/llm"
-import type { DocumentStore } from "../document-store"
-import type { ContextConfig } from "./config"
-import { estimate } from "./token-meter"
-
-// V3.8 Appendix-A C1.5 — chunked ingest. When an input (a big file / a batch / a whole book) itself
-// exceeds the 50% Working Set ceiling, we NEVER widen the working set. Instead we run a separate
-// map-reduce ingest that digests the big input, OUTSIDE the context, into a RETRIEVABLE memory doc:
-// 1. chunk by natural structure (chapters / files / modules / time windows), each far below the
-// ceiling (config.ingestChunkTokens),
-// 2. map: summarize each chunk -> one memory entry with a POSITION REFERENCE (offset/line/heading),
-// 3. reduce: fold the chunk summaries into a top-level memory,
-// 4. land it as a retrievable artifact (a `memory`/`artifact` doc) so later questions re-query the
-// relevant chunk by reference instead of re-reading the whole input.
-//
-// This module is the PURE pipeline (chunking + orchestration). The actual per-chunk summarization is
-// an INJECTED function (`summarize`) — same shape as the A4 map-reduce segment-summarize mechanism —
-// so this stays testable without an LLM: a test injects a deterministic summarizer. The 50% ceiling
-// is respected structurally: chunks are sized to ingestChunkTokens and only ONE chunk is "in hand" at
-// a time (the pipeline never accumulates the whole input in memory-as-context).
-
-export type Chunk = {
- readonly index: number
- readonly heading: string
- readonly text: string
- // Position reference back into the source for on-demand re-read (C1.5 §2 "原文位置引用").
- readonly startOffset: number
- readonly endOffset: number
-}
-
-// Split text into chunks by natural structure. Prefers markdown-style headings (# / ## ...); falls
-// back to blank-line paragraphs; then packs consecutive units so each chunk is <= targetTokens.
-// Never splits mid-line. Offsets are byte-in-string offsets into the original.
-export const chunkByStructure = (text: string, targetTokens: number): Chunk[] => {
- if (!text) return []
- const lines = text.split("\n")
- // Build units: a unit starts at each heading line, otherwise groups run until the next heading.
- type Unit = { heading: string; start: number; end: number }
- const units: Unit[] = []
- let offset = 0
- let cur: Unit | null = null
- const isHeading = (l: string) => /^#{1,6}\s+/.test(l)
- for (const line of lines) {
- const lineLen = line.length + 1 // +\n
- if (isHeading(line) || cur === null) {
- if (cur) units.push(cur)
- cur = { heading: isHeading(line) ? line.replace(/^#+\s+/, "").trim() : "section", start: offset, end: offset + lineLen }
- } else {
- cur.end = offset + lineLen
- }
- offset += lineLen
- }
- if (cur) units.push(cur)
-
- // Pack units into chunks under the token budget.
- const chunks: Chunk[] = []
- let buf: { heading: string; start: number; end: number; text: string } | null = null
- const flush = () => {
- if (!buf) return
- chunks.push({ index: chunks.length, heading: buf.heading, text: buf.text, startOffset: buf.start, endOffset: buf.end })
- buf = null
- }
- for (const u of units) {
- const utext = text.slice(u.start, u.end)
- if (!buf) {
- buf = { heading: u.heading, start: u.start, end: u.end, text: utext }
- } else if (estimate(buf.text + utext) <= targetTokens) {
- buf.text += utext
- buf.end = u.end
- } else {
- flush()
- buf = { heading: u.heading, start: u.start, end: u.end, text: utext }
- }
- // A single oversized unit still becomes its own chunk (can't split a line-run further here).
- if (buf && estimate(buf.text) > targetTokens) flush()
- }
- flush()
- return chunks
-}
-
-export type ChunkSummary = {
- readonly index: number
- readonly heading: string
- readonly summary: string
- readonly startOffset: number
- readonly endOffset: number
-}
-
-// The injected summarizer: chunk text -> a short summary. Effectful shape left to the caller (sync
-// here for the pure pipeline; the session-side caller can adapt an LLM call via a sync-over-async
-// bridge or precompute).
-export type Summarize = (chunk: Chunk) => string
-
-export type IngestResult = {
- readonly chunkSummaries: readonly ChunkSummary[]
- readonly topLevel: string
- // The persisted memory doc id (when a store is provided).
- readonly memoryDocId?: string
-}
-
-// Run the ingest pipeline over a big input. `sourceName` labels the memory doc (file path / title).
-// map: summarize each chunk; reduce: fold summaries into a top-level memory. When `store` is given,
-// persist a position-referenced `memory` doc (retrievable artifact) and return its id.
-export const ingest = (input: {
- sourceName: string
- text: string
- config: ContextConfig
- summarize: Summarize
- reduce?: (summaries: readonly ChunkSummary[]) => string
- store?: DocumentStore
- scope?: string
-}): IngestResult => {
- const chunks = chunkByStructure(input.text, input.config.ingestChunkTokens)
- const chunkSummaries: ChunkSummary[] = chunks.map((c) => ({
- index: c.index,
- heading: c.heading,
- summary: input.summarize(c),
- startOffset: c.startOffset,
- endOffset: c.endOffset,
- }))
- const topLevel = input.reduce
- ? input.reduce(chunkSummaries)
- : defaultReduce(input.sourceName, chunkSummaries)
-
- let memoryDocId: string | undefined
- if (input.store) {
- // The memory doc body keeps BOTH the top-level memory and each chunk's summary + position ref, so
- // a later question can locate the relevant chunk and re-read only that slice of the source.
- const body = JSON.stringify(
- {
- source: input.sourceName,
- topLevel,
- chunks: chunkSummaries,
- },
- null,
- 2,
- )
- const doc = input.store.upsert({
- type: "memory",
- scope: input.scope ?? "durable",
- idSlug: `ingest-${input.sourceName}`,
- description: `file memory: ${input.sourceName}`,
- body,
- tags: ["ingest", "file-memory"],
- provenance: { source: "runner" },
- // memory is a KNOWLEDGE_TYPE -> requires confidence. This is derived-from-source, medium
- // evidence with support = chunk count.
- confidence: { evidence_strength: "medium", support_count: chunkSummaries.length },
- })
- memoryDocId = doc.id
- }
-
- return { chunkSummaries, topLevel, ...(memoryDocId ? { memoryDocId } : {}) }
-}
-
-const defaultReduce = (source: string, summaries: readonly ChunkSummary[]): string =>
- [`# Memory: ${source}`, "", ...summaries.map((s) => `- [${s.heading}] ${s.summary}`)].join("\n")
-
-// Re-read a specific chunk's original text from the source using a stored position reference (C1.5
-// "按引用回查那一块的原文"). Pure slice — the caller supplies the original source text.
-export const rereadChunk = (sourceText: string, ref: { startOffset: number; endOffset: number }): string =>
- sourceText.slice(ref.startOffset, ref.endOffset)
-
-// --- production LLM summarizer adapter (C1.5 map step, real LLM) -----------------------------------
-//
-// The pure `ingest` above takes a SYNC `Summarize` (chunk -> string) so it stays testable without an
-// LLM. A real deployment must summarize each chunk with the model. That is inherently ASYNC, and the
-// sync boundary of `ingest` cannot call the LLM inline. We honestly bridge the gap the same way the A4
-// map-reduce mechanism does: PRE-COMPUTE every chunk summary via the LLM (async, concurrent, bounded),
-// then run the pure `ingest` with a sync lookup into the precomputed map. No sync-over-async blocking.
-//
-// This reuses the EXISTING LLM capability (@deepagent-code/llm LLMClient.generate — the same client the
-// session-side compaction summarizer streams through), not a parallel model path. The prompt mirrors
-// compaction's terse-summary style. Caps are LENIENT/configurable (maxSummaryTokens defaults high).
-
-export type LlmSummarizerOptions = {
- readonly model: Model
- // Lenient default; a chunk summary is short by design but we do not bake a tight cap.
- readonly maxSummaryTokens?: number
- // Bounded concurrency for the pre-summarize map (lenient default 4).
- readonly concurrency?: number
- // Optional instruction override; defaults to a terse map-step summary prompt.
- readonly instruction?: string
-}
-
-const DEFAULT_SUMMARY_TOKENS = 512
-const DEFAULT_INGEST_CONCURRENCY = 4
-const DEFAULT_SUMMARY_INSTRUCTION =
- "Summarize the following section in 1-3 terse bullet points. Preserve exact identifiers, file paths, " +
- "commands, and error strings. Output only the bullets, no preamble."
-
-// Summarize ONE chunk via the LLM. Effect over LLMClient.Service (provided by the caller's runtime —
-// the gateway already wires LLMClient.layer). Returns the assembled assistant text.
-export const summarizeChunkEffect = (
- chunk: Chunk,
- opts: LlmSummarizerOptions,
-): Effect.Effect =>
- Effect.gen(function* () {
- const instruction = opts.instruction ?? DEFAULT_SUMMARY_INSTRUCTION
- const prompt = `${instruction}\n\n`
- const response = yield* LLM.generate(
- LLM.request({
- model: opts.model,
- messages: [Message.user(prompt)],
- tools: [],
- generation: { maxTokens: opts.maxSummaryTokens ?? DEFAULT_SUMMARY_TOKENS },
- }),
- )
- return response.text.trim()
- }).pipe(
- // DEFAULT-SAFE: a per-chunk LLM failure (typed error or defect) degrades to a position-referenced
- // placeholder rather than failing the whole ingest — the chunk is still retrievable by heading +
- // offsets. matchCauseEffect recovers the CAUSE (defects included), consistent with the module.
- Effect.matchCauseEffect({
- onFailure: () => Effect.succeed(`[summary unavailable] ${chunk.heading}`),
- onSuccess: (text) => Effect.succeed(text),
- }),
- )
-
-// The Effect-returning production ingest: pre-summarize every chunk with the LLM (bounded concurrency),
-// then run the PURE `ingest` with a sync lookup into the precomputed summaries. Same output shape as
-// `ingest`; the only difference is the summarizer is a real model call resolved before the sync pass.
-export const ingestEffect = (input: {
- sourceName: string
- text: string
- config: ContextConfig
- summarizer: LlmSummarizerOptions
- reduce?: (summaries: readonly ChunkSummary[]) => string
- store?: DocumentStore
- scope?: string
-}): Effect.Effect =>
- Effect.gen(function* () {
- const chunks = chunkByStructure(input.text, input.config.ingestChunkTokens)
- const summaries = yield* Effect.forEach(chunks, (c) => summarizeChunkEffect(c, input.summarizer), {
- concurrency: input.summarizer.concurrency ?? DEFAULT_INGEST_CONCURRENCY,
- })
- // Precomputed map keyed by chunk index; the sync `ingest` looks up here (no async at the boundary).
- const byIndex = new Map(chunks.map((c, i) => [c.index, summaries[i]!]))
- return ingest({
- sourceName: input.sourceName,
- text: input.text,
- config: input.config,
- summarize: (c) => byIndex.get(c.index) ?? `[summary unavailable] ${c.heading}`,
- ...(input.reduce ? { reduce: input.reduce } : {}),
- ...(input.store ? { store: input.store } : {}),
- ...(input.scope ? { scope: input.scope } : {}),
- })
- })
diff --git a/packages/core/src/deepagent/context/token-meter.ts b/packages/core/src/deepagent/context/token-meter.ts
deleted file mode 100644
index f2979dfe..00000000
--- a/packages/core/src/deepagent/context/token-meter.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-// V3.8 Appendix-A C5 — token metering that the Curator's budgeted assembly relies on. Two rules:
-// 1. REAL usage preferred: when a provider reports actual token counts, use them; only estimate when
-// no real count is available. `preferReal` implements that precedence.
-// 2. Better estimate: the repo-wide fallback is chars/4 (util/token.ts), which is badly wrong for
-// CJK and code (App-A C5: "对中文和代码严重偏差"). `estimate` splits the text into CJK vs
-// ASCII/other and applies a per-class chars-per-token ratio, so a budget computed over Chinese or
-// dense code is far closer to reality. This stays additive: util/token.ts is untouched; this is
-// the context-management-local upgrade the Curator uses. chars/4 remains the floor when a string
-// has no CJK (ASCII ~4 chars/token is already decent).
-//
-// Not a tokenizer — no model BPE here (none available in-repo). It is a calibrated heuristic whose
-// only job is to keep the 50%-ceiling arithmetic honest enough that a real provider count rarely
-// surprises us. When the real count arrives it always wins.
-
-// CJK unified ideographs + common Han/Kana/Hangul ranges. Each such char is ~1 token (often <1 for
-// common Han under BPE, but 1 is a safe, slightly-conservative upper estimate that avoids
-// under-budgeting — the failure mode we care about is over-filling the window).
-const CJK_RE =
- /[ -〿-ヿ㐀-䶿一-鿿豈--가-]/u
-
-const ASCII_CHARS_PER_TOKEN = 4
-
-export const isCJK = (ch: string): boolean => CJK_RE.test(ch)
-
-// Estimate tokens for a string. CJK runs are counted ~1 token/char; the rest at ~ASCII_CHARS_PER_TOKEN
-// chars/token. Never negative; empty -> 0.
-export const estimate = (input: string): number => {
- if (!input) return 0
- let cjk = 0
- let other = 0
- for (const ch of input) {
- if (CJK_RE.test(ch)) cjk++
- else other++
- }
- return Math.max(0, Math.round(cjk + other / ASCII_CHARS_PER_TOKEN))
-}
-
-// Real provider token usage for one message/turn, if the provider reported it. All fields optional
-// so a partial report still yields a best real total.
-export type RealUsage = {
- readonly input?: number
- readonly output?: number
- readonly reasoning?: number
- readonly total?: number
- readonly cacheRead?: number
- readonly cacheWrite?: number
-}
-
-// Collapse a RealUsage to a single token count: prefer an explicit `total`, else sum the parts.
-// Returns undefined when nothing usable was reported (caller then estimates).
-export const realTotal = (usage: RealUsage | undefined): number | undefined => {
- if (!usage) return undefined
- if (typeof usage.total === "number" && usage.total > 0) return usage.total
- const parts = [usage.input, usage.output, usage.reasoning, usage.cacheRead, usage.cacheWrite].filter(
- (v): v is number => typeof v === "number" && v >= 0,
- )
- if (parts.length === 0) return undefined
- const sum = parts.reduce((a, b) => a + b, 0)
- return sum > 0 ? sum : undefined
-}
-
-// The C5 precedence in one call: real provider count when available, otherwise the CJK/code-aware
-// estimate over `text`. This is what the Curator uses to price an item.
-export const preferReal = (real: RealUsage | undefined, text: string): number => {
- const r = realTotal(real)
- return r !== undefined ? r : estimate(text)
-}
diff --git a/packages/core/src/deepagent/context/working-set.ts b/packages/core/src/deepagent/context/working-set.ts
deleted file mode 100644
index e968d32f..00000000
--- a/packages/core/src/deepagent/context/working-set.ts
+++ /dev/null
@@ -1,139 +0,0 @@
-import type { Ledger, LedgerEntry } from "./ledger"
-import { taskAnchor, currentNext, recallCandidates } from "./ledger"
-import type { ContextConfig } from "./config"
-import { workingSetBudgetTokens } from "./config"
-import { estimate } from "./token-meter"
-import { knowledgeSimilarity } from "../document-store"
-
-// V3.8 Appendix-A C1 — the Working Set Curator (public axiom 1: "不换对话 → 持续专注"). Each turn the
-// Curator BUILDS a budgeted working set instead of "take all history → compact when it overflows".
-// Composition, filled by fixed priority until the budget is spent:
-// 1. task anchor (NEVER dropped): active Goal + active Constraint (from the Ledger).
-// 2. near-field: the most recent N verbatim turns (short-term memory / coherence).
-// 3. active references: latest version of files/tool-results the current task touches.
-// 4. relevance recall: a few Ledger entries relevant to the current step (via GraphQuery-scored
-// keyword/token similarity — NO embeddings).
-// 5. budget guardrail: the whole set is <= workingSetBudgetTokens(context) — a HARD 50% ceiling.
-//
-// Reasoning is EXCLUDED by default (C1): it is drafted + logged but not carried to the next turn.
-// This module is a PURE assembler: it takes already-scored recall hits + the ledger + near-field
-// items and produces a budgeted plan. It does NOT do IO — the caller (a thin Effect service) supplies
-// the ledger, the recent turns, and the recall hits (obtained via GraphQuery). That keeps the 50%
-// arithmetic unit-testable with no store/provider wiring.
-
-export type WorkingSetItemKind = "anchor" | "near_field" | "reference" | "recall"
-
-// A candidate for admission to the working set. `tokens` may be supplied (real provider count, C5);
-// otherwise it is estimated from `text` with the CJK/code-aware estimator.
-export type WorkingSetCandidate = {
- readonly id: string
- readonly kind: WorkingSetItemKind
- readonly text: string
- // If the caller has a real/measured token count, pass it — the Curator prefers it over estimate().
- readonly tokens?: number
- // For recall ordering: higher first. Ignored for anchor/near_field (their own order is preserved).
- readonly score?: number
- // Marks reasoning-origin content so the exclude-reasoning guard can drop it (C1). Never true for
- // anchor items.
- readonly isReasoning?: boolean
-}
-
-export type WorkingSetItem = WorkingSetCandidate & { readonly tokens: number }
-
-export type WorkingSet = {
- readonly items: readonly WorkingSetItem[]
- readonly tokens: number
- readonly budget: number
- // Candidates that did NOT fit under the ceiling — the caller routes large/over-budget inputs here
- // to the C1.5 chunked-ingest path instead of growing the working set (C1.5: never widen the set).
- readonly overflow: readonly WorkingSetItem[]
-}
-
-const priceOf = (c: WorkingSetCandidate): number =>
- typeof c.tokens === "number" && c.tokens >= 0 ? c.tokens : estimate(c.text)
-
-// Assemble a budgeted working set from prioritized candidates under a HARD token ceiling.
-//
-// Enforcement (the 50% hard ceiling): `budget` is computed by workingSetBudgetTokens (fraction is
-// pre-clamped to <= MAX_BUDGET_FRACTION in config). Items are admitted in priority order and the
-// running total is asserted to NEVER exceed `budget`. The anchor is admitted first and is expected to
-// be tiny; if even the anchor exceeds budget we still stop at the ceiling (return only what fits) so
-// the invariant `result.tokens <= budget` ALWAYS holds — we never emit an over-budget set.
-export const assemble = (input: {
- contextTokens: number
- config: ContextConfig
- // Priority-ordered groups. anchor first, then near-field (most-recent last is fine — caller orders),
- // then references, then recall (already sorted best-first by the caller / GraphQuery score).
- anchor: readonly WorkingSetCandidate[]
- nearField: readonly WorkingSetCandidate[]
- references: readonly WorkingSetCandidate[]
- recall: readonly WorkingSetCandidate[]
-}): WorkingSet => {
- const budget = workingSetBudgetTokens(input.contextTokens, input.config)
- const admitted: WorkingSetItem[] = []
- const overflow: WorkingSetItem[] = []
- let total = 0
-
- const consider = (c: WorkingSetCandidate) => {
- // Reasoning is excluded from the working set by default (C1). Route it nowhere (it lives in the
- // Conversation Log, not here).
- if (input.config.excludeReasoning && c.isReasoning) return
- const tokens = priceOf(c)
- const item: WorkingSetItem = { ...c, tokens }
- // HARD CEILING: admit only if it keeps the running total within budget.
- if (total + tokens <= budget) {
- admitted.push(item)
- total += tokens
- } else {
- overflow.push(item)
- }
- }
-
- // Fixed priority order. Anchor first so the task never drops.
- for (const c of input.anchor) consider({ ...c, kind: "anchor", isReasoning: false })
- for (const c of input.nearField) consider({ ...c, kind: "near_field" })
- for (const c of input.references) consider({ ...c, kind: "reference" })
- // recall sorted best-first
- for (const c of [...input.recall].sort((a, b) => (b.score ?? 0) - (a.score ?? 0))) consider({ ...c, kind: "recall" })
-
- // Invariant assertion (belt-and-suspenders alongside config clamping): the emitted set is never
- // over the ceiling. This throws only on a real bug in the arithmetic above, never on user input
- // (over-budget input lands in `overflow`, not `admitted`).
- if (total > budget) {
- throw new Error(`working-set invariant violated: ${total} > ceiling ${budget}`)
- }
-
- return { items: admitted, tokens: total, budget, overflow }
-}
-
-// Build the anchor candidates from a ledger's task anchor (active goals + constraints) plus the
-// current live step (`next`). These are the never-drop items (C1 §1).
-export const anchorCandidates = (ledger: Ledger): WorkingSetCandidate[] => {
- const out: WorkingSetCandidate[] = taskAnchor(ledger).map((e) => entryCandidate(e, "anchor"))
- const next = currentNext(ledger)
- if (next) out.push(entryCandidate(next, "anchor"))
- return out
-}
-
-const entryCandidate = (e: LedgerEntry, kind: WorkingSetItemKind): WorkingSetCandidate => ({
- id: e.id,
- kind,
- text: e.rationale ? `[${e.kind}] ${e.text} — ${e.rationale}` : `[${e.kind}] ${e.text}`,
-})
-
-// Score ledger recall candidates against the current task using the SAME keyword/token similarity
-// primitive GraphQuery uses (knowledgeSimilarity — overlap coefficient, NO embeddings). This is the
-// in-ledger recall fallback used when a full GraphQuery pass is not wired; the Curator service prefers
-// GraphQuery hits and uses this only over the local ledger entries. Returns candidates sorted
-// best-first, capped to config.recallLimit, excluding the anchor kinds.
-export const ledgerRecall = (ledger: Ledger, task: string, config: ContextConfig): WorkingSetCandidate[] => {
- const scored = recallCandidates(ledger)
- .map((e) => {
- const sim = task && task.length > 0 ? knowledgeSimilarity(`${e.text} ${e.rationale ?? ""}`, task) : 0
- return { entry: e, score: sim }
- })
- .filter((s) => s.score > 0)
- .sort((a, b) => b.score - a.score)
- .slice(0, config.recallLimit)
- return scored.map((s) => ({ ...entryCandidate(s.entry, "recall"), score: s.score }))
-}
diff --git a/packages/core/src/deepagent/deepagent-event-bus.ts b/packages/core/src/deepagent/deepagent-event-bus.ts
new file mode 100644
index 00000000..efb72fa5
--- /dev/null
+++ b/packages/core/src/deepagent/deepagent-event-bus.ts
@@ -0,0 +1,778 @@
+export * as DeepAgentEventBus from "./deepagent-event-bus"
+
+import { Context, Effect, Layer, PubSub, Stream } from "effect"
+import { and, asc, desc, eq, lte, lt, gt, notExists, or, sql } from "drizzle-orm"
+import { alias } from "drizzle-orm/sqlite-core"
+import { Database } from "../database/database"
+import { DeepAgentEventDeliveryTable, DeepAgentEventDropTable, DeepAgentEventTable } from "./deepagent-event-sql"
+import { ApprovalQueueTable } from "./approval-queue-sql"
+import { DeepAgentEvent } from "./deepagent-event"
+import { RateLimiter } from "./rate-limiter"
+import { LMNEvents } from "./lmn-events"
+
+// §A3 DLQ alert — the event type the bus publishes when a delivery flips to "dead" (see nack).
+const DLQ_ALERT_TYPE = LMNEvents.DLQ_ALERT
+
+// V4.0 §A2 — the Event Bus service. Implements the §A2 contract (publish / subscribe / ack / nack /
+// replay) on the durable `deepagent_event` + `deepagent_event_delivery` tables (deepagent-event-sql.ts).
+//
+// DESIGN PRINCIPLE 1 (事件先持久化,再分发): `publish` writes the event row inside a transaction and
+// ONLY THEN fans it out to live subscribers. A process crash between persist and dispatch loses no
+// event — a subscriber that reconnects reads durable history via `replay`.
+//
+// §A3 contract enforced here:
+// 持久化 : publish returns success only after the row is committed.
+// 幂等 : idempotency_key is UNIQUE; a re-publish with the same key is a no-op returning the
+// already-persisted event (never a second row, never a second dispatch).
+// 顺序 : same-`correlationID` events keep causal order (single-writer append + created_at asc);
+// no global cross-correlation order is promised (§K non-goal).
+// 重试 : failed deliveries schedule an exponential backoff (base 1s, ×2 per attempt), default 3.
+// Dead Letter: attempts beyond the cap flip delivery.status → "dead" (the DLQ view).
+//
+// LAYERING: `core`. No LSP / panel / task-tool / session imports. The Router (§A4, deepagent-code)
+// subscribes here and dispatches to sessions/agents; this service never touches the runtime itself.
+
+// §A3 重试 defaults. Overridable per layer for tests / per-workspace tuning later.
+export const DEFAULT_MAX_ATTEMPTS = 3
+export const DEFAULT_BACKOFF_BASE_MS = 1000
+
+// §A4 去重窗口: within this window, a duplicate low-priority event of the same type is merged. The bus
+// exposes the primitive (recentByType); the Router applies the merge policy.
+export const DEFAULT_DEDUPE_WINDOW_MS = 10_000
+
+// §A4/§E2 tryPublish outcome — a discriminated union so a caller learns whether the event was shed by
+// the rate gate. `published` carries the persisted event (identical to `publish`'s result); `dropped`
+// signals the low/normal event exceeded the per-workspace ceiling and was NOT persisted.
+export type TryPublishResult =
+ | { readonly published: DeepAgentEvent.Event }
+ | { readonly dropped: "rate_limited" }
+
+export interface DeliveryTracker {
+ readonly eventID: DeepAgentEvent.ID
+ readonly subscriptionGroup: string
+ readonly status: "pending" | "delivered" | "dead"
+ readonly attempts: number
+ readonly lastError?: string
+ readonly nextAttemptAt?: number
+}
+
+export interface Interface {
+ /**
+ * §A3 持久化 + 幂等. Normalizes a PublishInput into a full DeepAgentEvent, commits it, then dispatches
+ * to live subscribers. A duplicate idempotency_key returns the existing event without re-dispatch.
+ */
+ readonly publish: (input: DeepAgentEvent.PublishInput) => Effect.Effect
+ /**
+ * §A4/§E2 rate-gated publish. Applies the per-workspace event-publish rate ceiling
+ * (EVENT_PUBLISH_PER_WORKSPACE, or `opts.limit`) BEFORE persisting, then delegates to `publish`.
+ *
+ * PRIORITY BYPASS (§A4): high/critical events ALWAYS publish (never dropped) — the ceiling only sheds
+ * low/normal load. A low/normal event over the ceiling returns `{ dropped: "rate_limited" }` and is
+ * NOT persisted (no row, no dispatch). Otherwise the persisted event is returned as `{ published }`.
+ * The discriminated union lets the caller observe the drop (e.g. to log a blocked-push counter).
+ *
+ * Keyed per workspaceID (fixed-window, in-memory). This is ADDITIVE — existing `publish` callers are
+ * untouched and bypass the gate entirely.
+ */
+ readonly tryPublish: (
+ input: DeepAgentEvent.PublishInput,
+ opts?: { readonly limit?: number },
+ ) => Effect.Effect
+ /**
+ * Live stream of newly published events (post-persist). Historical events come from `replay`.
+ *
+ * DELIVERY TRACKING: when `group` is supplied the subscriber joins a durable consumer group — for
+ * the lifetime of the stream's scope the bus records a `pending` delivery row for that group on
+ * every matching `publish` (BEFORE the event reaches the stream), so a crash between receipt and
+ * `ack` is recoverable via `dueRetries` (§A3 at-least-once). The consumer MUST `ack`/`nack` each
+ * event. Group-less subscribers are anonymous observers: pure live broadcast, no delivery tracking,
+ * best-effort only. Multi-worker competing-consumer WITHIN one group is a distributed-backend
+ * concern (§A2 Redis/Kafka); the in-memory bus broadcasts to every live stream of the group.
+ */
+ readonly subscribe: (input: {
+ readonly type?: string
+ readonly group?: string
+ }) => Stream.Stream
+ /** §A2 ack — mark a (event, group) delivery successful. Idempotent. */
+ readonly ack: (subscriptionGroup: string, eventID: DeepAgentEvent.ID) => Effect.Effect
+ /** §A2 nack — record a failed delivery; schedules retry or flips to DLQ past the attempt cap. */
+ readonly nack: (input: {
+ readonly subscriptionGroup: string
+ readonly eventID: DeepAgentEvent.ID
+ readonly reason: string
+ }) => Effect.Effect
+ /** §A2 replay — durable history for a type/time window (crash recovery + late subscribers). */
+ readonly replay: (input: {
+ readonly type?: string
+ readonly workspaceID?: string
+ readonly from: number
+ readonly to?: number
+ }) => Stream.Stream
+ /**
+ * §A4 去重窗口 primitive — recent same-type events for the Router's dedupe merge. Pass `workspaceID`
+ * to scope the window to one tenant (the Router MUST, so a duplicate in workspace A never suppresses
+ * an event in workspace B); omit only for cross-tenant maintenance scans.
+ */
+ readonly recentByType: (input: {
+ readonly type: string
+ readonly workspaceID?: string
+ readonly windowMs?: number
+ readonly now?: number
+ }) => Effect.Effect>
+ /** §A Dead Letter view — deliveries that exhausted retries. */
+ readonly deadLetters: () => Effect.Effect>
+ /**
+ * §A4 event_dropped — persist an append-only DROP RECORD for an event the router SHED (a backpressure
+ * drop). Makes event_dropped a queryable/persisted signal (Observability.event_dropped_total by
+ * reason), mirroring how dead deliveries feed dlq_events_total. FAIL-SAFE: never fails — a drop record
+ * is best-effort audit, and a write hiccup must not perturb the caller's ack/nack path (Effect.orDie is
+ * intentionally avoided; a write failure is swallowed).
+ */
+ readonly recordDrop: (input: {
+ readonly event: DeepAgentEvent.Event
+ readonly reason: string
+ }) => Effect.Effect
+ /** §A3 retry scan — pending deliveries whose backoff has elapsed (Router/Scheduler drives re-delivery). */
+ readonly dueRetries: (now?: number) => Effect.Effect>
+ /** Load a single event by id from the durable log — used by the retry pump to re-dispatch a nacked delivery. */
+ readonly getByID: (eventID: DeepAgentEvent.ID) => Effect.Effect
+ /**
+ * §A3 保留期 (retention sweep) — delete durable events for one workspace older than `olderThan`
+ * (epoch ms, exclusive), returning how many event rows were removed.
+ *
+ * REFERENTIAL SAFETY (an event still owed to a human/consumer MUST survive its retention window):
+ * - an event with a PENDING `deepagent_event_delivery` (status='pending') is EXCLUDED — an unacked
+ * at-least-once delivery still owes the event to a consumer group; deleting it would strand the
+ * retry pump (which loads the event by id to re-dispatch).
+ * - an event referenced by an UNRESOLVED `deepagent_approval_queue` row (status='pending') is
+ * EXCLUDED — a human still has to act on it; its source event must remain for the trace + payload.
+ * - `delivered`/`dead` deliveries do NOT protect an event (terminal), and cascade-delete with it.
+ *
+ * Workspace-scoped via `deepagent_event_workspace_created_idx`. Delivery rows for deleted events are
+ * removed by the FK ON DELETE CASCADE (PRAGMA foreign_keys=ON).
+ */
+ readonly sweep: (input: {
+ readonly workspaceID: string
+ readonly olderThan: number
+ }) => Effect.Effect<{ readonly deletedEvents: number }>
+ /**
+ * §E2 — prune the publish rate-limiter's per-workspace buckets whose fixed window has elapsed as of
+ * `now`, bounding the limiter's memory for idle workspaces. Returns how many buckets were dropped.
+ * The limiter lives inside this layer's closure; this exposes its `sweep` so a periodic daemon
+ * (v4-event-runtime) can drive it on a cadence without reaching into private state. `now` defaults to
+ * the injected clock (deterministic in tests). A no-op that never throws — safe to call any time.
+ */
+ readonly sweepPublishLimiter: (now?: number) => Effect.Effect<{ readonly prunedBuckets: number }>
+}
+
+export class Service extends Context.Service()("@deepagent-code/DeepAgentEventBus") {}
+
+export interface LayerOptions {
+ readonly maxAttempts?: number
+ readonly backoffBaseMs?: number
+ readonly now?: () => number
+}
+
+const decodeRow = (row: {
+ id: string
+ type: string
+ source: string
+ workspace_id: string
+ project_id: string | null
+ actor_id: string | null
+ correlation_id: string | null
+ causation_id: string | null
+ idempotency_key: string
+ priority: string
+ payload: unknown
+ created_at: number
+}): DeepAgentEvent.Event => ({
+ id: row.id as DeepAgentEvent.ID,
+ type: row.type,
+ source: row.source as DeepAgentEvent.EventSource,
+ workspaceID: row.workspace_id,
+ ...(row.project_id != null ? { projectID: row.project_id } : {}),
+ ...(row.actor_id != null ? { actorID: row.actor_id } : {}),
+ ...(row.correlation_id != null ? { correlationID: row.correlation_id } : {}),
+ ...(row.causation_id != null ? { causationID: row.causation_id } : {}),
+ idempotencyKey: row.idempotency_key,
+ priority: row.priority as DeepAgentEvent.EventPriority,
+ createdAt: row.created_at,
+ payload: row.payload ?? undefined,
+})
+
+export const layerWith = (options?: LayerOptions) =>
+ Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const { db } = yield* Database.Service
+ const maxAttempts = options?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS
+ const backoffBaseMs = options?.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS
+ const now = options?.now ?? Date.now
+ const live = yield* PubSub.unbounded()
+ // §A4/§E2 — ONE in-memory fixed-window limiter for the whole bus, keyed per workspaceID. Only
+ // `tryPublish` consults it; `publish` is unchanged. `now` (the injected clock) drives window
+ // resets so tests cross a boundary deterministically.
+ const publishLimiter = new RateLimiter.Service()
+
+ yield* Effect.addFinalizer(() => PubSub.shutdown(live))
+
+ // §A3 at-least-once — the set of consumer groups with a live `subscribe({group})` stream, and
+ // the type filter each declared. `publish` writes a durable `pending` delivery row for every
+ // group whose filter matches, so an event owed to a group survives a crash between receipt and
+ // ack (recoverable via `dueRetries`). Ref-counted: a group is registered while ≥1 of its streams
+ // is live and dropped when the last unsubscribes, so we never accrue deliveries no one consumes.
+ const groups = new Map }>()
+ const registerGroup = (group: string, type: string | null) =>
+ Effect.sync(() => {
+ const entry = groups.get(group) ?? { types: new Map() }
+ entry.types.set(type, (entry.types.get(type) ?? 0) + 1)
+ groups.set(group, entry)
+ })
+ const unregisterGroup = (group: string, type: string | null) =>
+ Effect.sync(() => {
+ const entry = groups.get(group)
+ if (!entry) return
+ const next = (entry.types.get(type) ?? 0) - 1
+ if (next <= 0) entry.types.delete(type)
+ else entry.types.set(type, next)
+ if (entry.types.size === 0) groups.delete(group)
+ })
+ // groups owed a delivery for `event`: any live group with a wildcard (null) filter or a filter
+ // matching the event's type.
+ const groupsFor = (eventType: string): ReadonlyArray => {
+ const out: string[] = []
+ for (const [group, entry] of groups) {
+ if (entry.types.has(null) || entry.types.has(eventType)) out.push(group)
+ }
+ return out
+ }
+
+ const publish: Interface["publish"] = (input) =>
+ Effect.gen(function* () {
+ // §A3 幂等: if an event with this idempotency key exists, return it without a second row/dispatch.
+ const key = input.idempotencyKey ?? DeepAgentEvent.ID.create()
+ const existing = yield* db
+ .select()
+ .from(DeepAgentEventTable)
+ .where(eq(DeepAgentEventTable.idempotency_key, key))
+ .get()
+ .pipe(Effect.orDie)
+ if (existing) return decodeRow(existing)
+
+ const createdAt = now()
+ const event: DeepAgentEvent.Event = {
+ id: DeepAgentEvent.ID.create(createdAt), // §A1: id time component tracks createdAt (#7)
+ type: input.type,
+ source: input.source,
+ workspaceID: input.workspaceID,
+ ...(input.projectID != null ? { projectID: input.projectID } : {}),
+ ...(input.actorID != null ? { actorID: input.actorID } : {}),
+ ...(input.correlationID != null ? { correlationID: input.correlationID } : {}),
+ ...(input.causationID != null ? { causationID: input.causationID } : {}),
+ idempotencyKey: key,
+ priority: input.priority ?? "normal",
+ createdAt,
+ payload: input.payload,
+ }
+
+ // §A3 持久化 + at-least-once: in ONE immediate transaction, insert the event row and — only
+ // if WE won the insert — a `pending` delivery row per live consumer group owed this type.
+ // `.returning()` tells us whether the insert actually landed (a racing duplicate that slips
+ // past the read-check above hits UNIQUE(idempotency_key) → 0 rows → not the winner). Dispatch
+ // happens AFTER commit, so a subscriber never observes an uncommitted event.
+ const owed = groupsFor(event.type)
+ // §F1 event_publish_latency_ms — wall-clock delta (injected clock) around the persist
+ // transaction. One now() before, one after; the delta is written on the row so Observability
+ // can build the publish-latency histogram. Cheap + additive.
+ const publishStart = now()
+ const wonInsert = yield* db
+ .transaction(
+ () =>
+ Effect.gen(function* () {
+ const returned = yield* db
+ .insert(DeepAgentEventTable)
+ .values([
+ {
+ id: event.id,
+ type: event.type,
+ source: event.source,
+ workspace_id: event.workspaceID,
+ project_id: event.projectID ?? null,
+ actor_id: event.actorID ?? null,
+ correlation_id: event.correlationID ?? null,
+ causation_id: event.causationID ?? null,
+ idempotency_key: event.idempotencyKey,
+ priority: event.priority,
+ payload: event.payload ?? null,
+ created_at: event.createdAt,
+ },
+ ])
+ .onConflictDoNothing({ target: DeepAgentEventTable.idempotency_key })
+ .returning({ id: DeepAgentEventTable.id })
+ .all()
+ .pipe(Effect.orDie)
+ const won = returned.length > 0
+ if (won && owed.length > 0) {
+ yield* db
+ .insert(DeepAgentEventDeliveryTable)
+ .values(
+ owed.map((group) => ({
+ event_id: event.id,
+ subscription_group: group,
+ status: "pending" as const,
+ attempts: 0,
+ last_error: null,
+ next_attempt_at: createdAt, // owed immediately until acked
+ created_at: createdAt,
+ updated_at: createdAt,
+ })),
+ )
+ // a group already tracked for this event (shouldn't happen pre-dispatch) is a no-op.
+ .onConflictDoNothing({
+ target: [
+ DeepAgentEventDeliveryTable.event_id,
+ DeepAgentEventDeliveryTable.subscription_group,
+ ],
+ })
+ .run()
+ .pipe(Effect.orDie)
+ }
+ return won
+ }),
+ { behavior: "immediate" },
+ )
+ .pipe(Effect.orDie)
+
+ if (!wonInsert) {
+ // Idempotent no-op: the winner's row is authoritative — return it, never re-dispatch.
+ const winner = yield* db
+ .select()
+ .from(DeepAgentEventTable)
+ .where(eq(DeepAgentEventTable.idempotency_key, key))
+ .get()
+ .pipe(Effect.orDie)
+ return winner ? decodeRow(winner) : event
+ }
+ // §F1 — record the persist latency on the row (delta of the two clock reads around the
+ // commit). Non-fatal + additive: a lightweight UPDATE that never blocks dispatch.
+ const publishLatencyMs = now() - publishStart
+ yield* db
+ .update(DeepAgentEventTable)
+ .set({ publish_latency_ms: publishLatencyMs })
+ .where(eq(DeepAgentEventTable.id, event.id))
+ .run()
+ .pipe(Effect.orDie)
+ yield* PubSub.publish(live, event)
+ return event
+ })
+
+ // §A4/§E2 rate-gated publish — see the Interface doc. Priority bypass first (high/critical always
+ // pass), then the fixed-window ceiling for low/normal; under the ceiling we delegate to `publish`.
+ const tryPublish: Interface["tryPublish"] = (input, opts) =>
+ Effect.gen(function* () {
+ const priority = input.priority ?? "normal"
+ // §A4: high/critical are never shed — publish unconditionally (still records a hit-free path).
+ if (priority === "high" || priority === "critical") {
+ return { published: yield* publish(input) }
+ }
+ const limit = opts?.limit ?? RateLimiter.EVENT_PUBLISH_PER_WORKSPACE.limit
+ const admitted = publishLimiter.check(
+ input.workspaceID,
+ limit,
+ RateLimiter.EVENT_PUBLISH_PER_WORKSPACE.windowMs,
+ now(),
+ )
+ if (!admitted) return { dropped: "rate_limited" as const }
+ return { published: yield* publish(input) }
+ })
+
+ const subscribe: Interface["subscribe"] = (input) => {
+ const filtered = Stream.fromPubSub(live).pipe(
+ Stream.filter((event) => (input.type ? event.type === input.type : true)),
+ )
+ // A grouped subscriber declares a durable consumer group: register it for the stream's scope so
+ // `publish` writes `pending` delivery rows it must ack (§A3 at-least-once). Anonymous
+ // subscribers (no group) are pure live observers — no delivery tracking.
+ if (input.group == null) return filtered
+ const group = input.group
+ const type = input.type ?? null
+ return filtered.pipe(
+ Stream.onStart(registerGroup(group, type)),
+ Stream.ensuring(unregisterGroup(group, type)),
+ )
+ }
+
+ const ack: Interface["ack"] = (subscriptionGroup, eventID) => {
+ const at = now()
+ return db
+ .insert(DeepAgentEventDeliveryTable)
+ .values([
+ {
+ event_id: eventID,
+ subscription_group: subscriptionGroup,
+ status: "delivered",
+ attempts: 0, // ack of a never-failed delivery: no attempt was consumed (#8)
+ last_error: null,
+ next_attempt_at: null,
+ created_at: at,
+ updated_at: at,
+ },
+ ])
+ .onConflictDoUpdate({
+ target: [DeepAgentEventDeliveryTable.event_id, DeepAgentEventDeliveryTable.subscription_group],
+ // clear retry state; leave `attempts` as the historical count of prior failures.
+ set: { status: "delivered", last_error: null, next_attempt_at: null, updated_at: at },
+ })
+ .run()
+ .pipe(Effect.orDie, Effect.asVoid)
+ }
+
+ // §A3 重试 — record a failed delivery. The read-modify-write on `attempts` runs inside an
+ // immediate transaction wrapped in `Effect.uninterruptible` (mirroring event.ts) so two
+ // concurrent nacks for the same (event, group) can't both read attempts=N and both write N+1 —
+ // the second serializes behind the first and reads N+1. Without this the DLQ transition
+ // (attempts ≥ maxAttempts) could be delayed or skipped under concurrent failures.
+ const nack: Interface["nack"] = (input) =>
+ Effect.gen(function* () {
+ // The transaction returns whether THIS nack flipped the delivery to "dead" for the FIRST time
+ // (a fresh DLQ transition), plus the final attempt count — so the §A3 alert fires once, after
+ // commit, only on the transition (not on a nack of an already-dead delivery).
+ const transition = yield* Effect.uninterruptible(
+ db
+ .transaction(
+ () =>
+ Effect.gen(function* () {
+ const current = yield* db
+ .select()
+ .from(DeepAgentEventDeliveryTable)
+ .where(
+ and(
+ eq(DeepAgentEventDeliveryTable.event_id, input.eventID),
+ eq(DeepAgentEventDeliveryTable.subscription_group, input.subscriptionGroup),
+ ),
+ )
+ .get()
+ .pipe(Effect.orDie)
+ const attempts = (current?.attempts ?? 0) + 1
+ // §A Dead Letter: past the cap the delivery is dead (surfaces in the DLQ view); else
+ // schedule the next retry with exponential backoff (base × 2^(attempts-1)).
+ const dead = attempts >= maxAttempts
+ const at = now()
+ const nextAttemptAt = dead ? null : at + backoffBaseMs * 2 ** (attempts - 1)
+ yield* db
+ .insert(DeepAgentEventDeliveryTable)
+ .values([
+ {
+ event_id: input.eventID,
+ subscription_group: input.subscriptionGroup,
+ status: dead ? "dead" : "pending",
+ attempts,
+ last_error: input.reason,
+ next_attempt_at: nextAttemptAt,
+ created_at: at,
+ updated_at: at,
+ },
+ ])
+ .onConflictDoUpdate({
+ target: [
+ DeepAgentEventDeliveryTable.event_id,
+ DeepAgentEventDeliveryTable.subscription_group,
+ ],
+ set: {
+ status: dead ? "dead" : "pending",
+ attempts,
+ last_error: input.reason,
+ next_attempt_at: nextAttemptAt,
+ updated_at: at,
+ },
+ })
+ .run()
+ .pipe(Effect.orDie)
+ // FIRST transition = it is dead now AND was not already dead before this nack.
+ return { justDied: dead && current?.status !== "dead", attempts }
+ }),
+ { behavior: "immediate" },
+ )
+ .pipe(Effect.orDie),
+ )
+ // §A3 "生成告警" — a dead-letter must fire an alert, not sit silently in the DLQ view. Emit ONCE
+ // on the transition. FAIL-SAFE: the whole alert path is caught so an alert failure never breaks
+ // the nack contract (the delivery is already durably "dead"; the alert is best-effort notify).
+ if (transition.justDied) {
+ yield* emitDlqAlert(input.eventID, input.subscriptionGroup, input.reason, transition.attempts).pipe(
+ Effect.catchCause(() => Effect.void),
+ )
+ }
+ })
+
+ // §A3 DLQ alert — publish a system `dlq.alert` (high priority) for a delivery that just exhausted
+ // its retries. IDEMPOTENT: the idempotency key is (event, group)-stable so a re-emit is a bus no-op
+ // (never a second alert). SELF-CASCADE GUARD: if the DEAD event is itself a dlq.alert, do NOT alert
+ // on it (an alert whose own delivery dies must not spawn another alert). Best-effort by construction
+ // — the caller wraps this in catchCause so any failure is swallowed.
+ const emitDlqAlert = (
+ eventID: DeepAgentEvent.ID,
+ subscriptionGroup: string,
+ reason: string,
+ attempts: number,
+ ) =>
+ Effect.gen(function* () {
+ const dead = yield* getByID(eventID)
+ // guard: never alert on a dead dlq.alert (severs the alert-of-an-alert cascade).
+ if (dead && dead.type === DLQ_ALERT_TYPE) return
+ yield* publish({
+ type: DLQ_ALERT_TYPE,
+ source: "system",
+ workspaceID: dead?.workspaceID ?? "system",
+ ...(dead?.projectID != null ? { projectID: dead.projectID } : {}),
+ // chain the alert to the dead event's correlation so it lands on the same §F2 trace spine.
+ correlationID: dead?.correlationID ?? eventID,
+ causationID: eventID,
+ // (event, group)-keyed ⇒ one alert per dead delivery, idempotent across re-emits.
+ idempotencyKey: `dlq-alert:${eventID}:${subscriptionGroup}`,
+ priority: "high",
+ payload: {
+ deadEventID: eventID,
+ deadEventType: dead?.type,
+ subscriptionGroup,
+ reason,
+ attempts,
+ },
+ })
+ })
+
+ const replay: Interface["replay"] = (input) =>
+ Stream.unwrap(
+ Effect.gen(function* () {
+ const conditions = [gt(DeepAgentEventTable.created_at, input.from - 1)]
+ if (input.to != null) conditions.push(lte(DeepAgentEventTable.created_at, input.to))
+ if (input.type != null) conditions.push(eq(DeepAgentEventTable.type, input.type))
+ if (input.workspaceID != null)
+ conditions.push(eq(DeepAgentEventTable.workspace_id, input.workspaceID))
+ const rows = yield* db
+ .select()
+ .from(DeepAgentEventTable)
+ .where(and(...conditions))
+ // id tiebreak: created_at is ms-resolution, so same-ms events would otherwise order
+ // nondeterministically — breaking the §A3 same-correlation causal-order guarantee. ids
+ // are ascending-monotonic, so (created_at asc, id asc) is a total, stable order (#4).
+ .orderBy(asc(DeepAgentEventTable.created_at), asc(DeepAgentEventTable.id))
+ .all()
+ .pipe(Effect.orDie)
+ return Stream.fromIterable(rows.map(decodeRow))
+ }),
+ )
+
+ const recentByType: Interface["recentByType"] = (input) =>
+ Effect.gen(function* () {
+ const windowMs = input.windowMs ?? DEFAULT_DEDUPE_WINDOW_MS
+ const at = input.now ?? now()
+ const conditions = [
+ eq(DeepAgentEventTable.type, input.type),
+ gt(DeepAgentEventTable.created_at, at - windowMs),
+ ]
+ // §多租户: scope the dedupe window to one workspace so a duplicate in A can't suppress B (#5).
+ if (input.workspaceID != null)
+ conditions.push(eq(DeepAgentEventTable.workspace_id, input.workspaceID))
+ const rows = yield* db
+ .select()
+ .from(DeepAgentEventTable)
+ .where(and(...conditions))
+ .orderBy(desc(DeepAgentEventTable.created_at), desc(DeepAgentEventTable.id))
+ .all()
+ .pipe(Effect.orDie)
+ return rows.map(decodeRow)
+ })
+
+ const trackerOf = (row: {
+ event_id: string
+ subscription_group: string
+ status: string
+ attempts: number
+ last_error: string | null
+ next_attempt_at: number | null
+ }): DeliveryTracker => ({
+ eventID: row.event_id as DeepAgentEvent.ID,
+ subscriptionGroup: row.subscription_group,
+ status: row.status as DeliveryTracker["status"],
+ attempts: row.attempts,
+ ...(row.last_error != null ? { lastError: row.last_error } : {}),
+ ...(row.next_attempt_at != null ? { nextAttemptAt: row.next_attempt_at } : {}),
+ })
+
+ const deadLetters: Interface["deadLetters"] = () =>
+ db
+ .select()
+ .from(DeepAgentEventDeliveryTable)
+ .where(eq(DeepAgentEventDeliveryTable.status, "dead"))
+ .all()
+ .pipe(Effect.orDie, Effect.map((rows) => rows.map(trackerOf)))
+
+ // §A4 event_dropped — append a durable drop record. Best-effort: a write failure is caught + logged
+ // (not orDie'd) so a drop-audit hiccup never perturbs the caller's ack/nack path.
+ // DISTINCT-event semantics: the backpressure path calls recordDrop on EVERY shed pass, so one logical
+ // event shed→nacked→re-shed ×N would otherwise write N rows and inflate event_dropped_total to count
+ // shed-ATTEMPTS, not distinct events. onConflictDoNothing on the UNIQUE event_id index makes a re-shed
+ // of the same event a no-op → COUNT(*) == distinct events shed.
+ const recordDrop: Interface["recordDrop"] = (input) =>
+ db
+ .insert(DeepAgentEventDropTable)
+ .values([
+ {
+ event_id: input.event.id,
+ workspace_id: input.event.workspaceID,
+ reason: input.reason,
+ priority: input.event.priority,
+ created_at: now(),
+ },
+ ])
+ .onConflictDoNothing({ target: DeepAgentEventDropTable.event_id })
+ .run()
+ .pipe(
+ Effect.catchCause(() => Effect.void),
+ Effect.asVoid,
+ )
+
+ const dueRetries: Interface["dueRetries"] = (nowArg) =>
+ Effect.gen(function* () {
+ const at = nowArg ?? now()
+ const rows = yield* db
+ .select()
+ .from(DeepAgentEventDeliveryTable)
+ .where(
+ and(
+ eq(DeepAgentEventDeliveryTable.status, "pending"),
+ lte(DeepAgentEventDeliveryTable.next_attempt_at, at),
+ ),
+ )
+ .orderBy(asc(DeepAgentEventDeliveryTable.next_attempt_at))
+ .all()
+ .pipe(Effect.orDie)
+ return rows.map(trackerOf)
+ })
+
+ const getByID: Interface["getByID"] = (eventID) =>
+ db
+ .select()
+ .from(DeepAgentEventTable)
+ .where(eq(DeepAgentEventTable.id, eventID))
+ .get()
+ .pipe(Effect.orDie, Effect.map((row) => (row ? decodeRow(row) : undefined)))
+
+ // §A3 保留期 — delete this workspace's events older than `olderThan`, SPARING any event still owed
+ // to a consumer (a pending delivery) or a human (an unresolved approval-queue item). Runs in an
+ // immediate transaction so the count reflects exactly what was removed. Delivery rows for the
+ // deleted events cascade via the FK (foreign_keys=ON) — we assert that below with a defensive
+ // cleanup that is a no-op when the cascade fires as expected.
+ const sweep: Interface["sweep"] = (input) =>
+ db
+ .transaction(
+ () =>
+ Effect.gen(function* () {
+ // an event is DELETABLE iff: this workspace, older than the cutoff, AND not referenced by
+ // a pending delivery, AND not referenced by a pending approval-queue row. The two
+ // notExists sub-selects are the referential-safety guard.
+ const noPendingDelivery = notExists(
+ db
+ .select({ one: sql`1` })
+ .from(DeepAgentEventDeliveryTable)
+ .where(
+ and(
+ eq(DeepAgentEventDeliveryTable.event_id, DeepAgentEventTable.id),
+ eq(DeepAgentEventDeliveryTable.status, "pending"),
+ ),
+ ),
+ )
+ const noPendingApproval = notExists(
+ db
+ .select({ one: sql`1` })
+ .from(ApprovalQueueTable)
+ .where(
+ and(
+ eq(ApprovalQueueTable.event_id, DeepAgentEventTable.id),
+ eq(ApprovalQueueTable.status, "pending"),
+ ),
+ ),
+ )
+ // A `dlq.alert` fires at nack-time — long after the dead event it references was created —
+ // so the dead event ages past the cutoff and would be swept while its alert survives,
+ // leaving a dangling `causationID`/`payload.deadEventID` trace ref. Spare any event still
+ // referenced by a live dlq.alert (by causation id OR the payload's deadEventID).
+ const dlqAlert = alias(DeepAgentEventTable, "dlq_alert")
+ const noLiveDlqAlert = notExists(
+ db
+ .select({ one: sql`1` })
+ .from(dlqAlert)
+ .where(
+ and(
+ eq(dlqAlert.type, DLQ_ALERT_TYPE),
+ or(
+ eq(dlqAlert.causation_id, DeepAgentEventTable.id),
+ eq(sql`json_extract(${dlqAlert.payload}, '$.deadEventID')`, DeepAgentEventTable.id),
+ ),
+ ),
+ ),
+ )
+ const deletable = and(
+ eq(DeepAgentEventTable.workspace_id, input.workspaceID),
+ lt(DeepAgentEventTable.created_at, input.olderThan),
+ noPendingDelivery,
+ noPendingApproval,
+ noLiveDlqAlert,
+ )
+
+ // Delete the terminal (delivered/dead) delivery rows of the doomed events FIRST. The FK
+ // cascade already removes them, but doing it explicitly keeps the sweep correct even if a
+ // future backend runs with foreign_keys OFF, and never touches a `pending` delivery (those
+ // events are excluded by `deletable`, so their deliveries aren't in this set).
+ yield* db
+ .delete(DeepAgentEventDeliveryTable)
+ .where(
+ sql`${DeepAgentEventDeliveryTable.event_id} in (${db
+ .select({ id: DeepAgentEventTable.id })
+ .from(DeepAgentEventTable)
+ .where(deletable)})`,
+ )
+ .run()
+ .pipe(Effect.orDie)
+
+ const deleted = yield* db
+ .delete(DeepAgentEventTable)
+ .where(deletable)
+ .returning({ id: DeepAgentEventTable.id })
+ .all()
+ .pipe(Effect.orDie)
+
+ return { deletedEvents: deleted.length }
+ }),
+ { behavior: "immediate" },
+ )
+ .pipe(Effect.orDie)
+
+ // §E2 — drive the in-memory rate-limiter's stale-window prune. Synchronous + total (never fails),
+ // wrapped in Effect.sync so the daemon can schedule it uniformly with the other bus effects.
+ const sweepPublishLimiter: Interface["sweepPublishLimiter"] = (nowArg) =>
+ Effect.sync(() => ({ prunedBuckets: publishLimiter.sweep(nowArg ?? now()) }))
+
+ return Service.of({
+ publish,
+ tryPublish,
+ subscribe,
+ ack,
+ nack,
+ replay,
+ recentByType,
+ deadLetters,
+ recordDrop,
+ dueRetries,
+ getByID,
+ sweep,
+ sweepPublishLimiter,
+ })
+ }),
+ )
+
+export const layer = layerWith()
+
+export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
diff --git a/packages/core/src/deepagent/deepagent-event-sql.ts b/packages/core/src/deepagent/deepagent-event-sql.ts
new file mode 100644
index 00000000..08663c82
--- /dev/null
+++ b/packages/core/src/deepagent/deepagent-event-sql.ts
@@ -0,0 +1,100 @@
+import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core"
+import type { DeepAgentEvent } from "./deepagent-event"
+
+// V4.0 §A3 — durable persistence for the DeepAgent Event Bus. The design principle is "事件先持久化,
+// 再分发" (§设计原则1): publish() writes here in a transaction BEFORE any dispatch, so a crash never
+// loses a triggered event. These tables sit ALONGSIDE the lower-level EventV2 `event`/`event_sequence`
+// tables (core/src/event/sql.ts) — that log is the per-aggregate sync substrate; this one is the
+// domain-event bus with retry/DLQ/priority/dedup semantics EventV2 does not model.
+
+// The main event log. One row per published DeepAgentEvent. `idempotency_key` is UNIQUE — the §A3 幂等
+// contract is enforced at the storage layer (a duplicate publish is a no-op, not a second row).
+export const DeepAgentEventTable = sqliteTable(
+ "deepagent_event",
+ {
+ id: text().$type().primaryKey(),
+ type: text().notNull(),
+ source: text().$type().notNull(),
+ workspace_id: text().notNull(),
+ project_id: text(),
+ actor_id: text(),
+ correlation_id: text(),
+ causation_id: text(),
+ idempotency_key: text().notNull(),
+ priority: text().$type().notNull(),
+ payload: text({ mode: "json" }).$type(),
+ created_at: integer().notNull(),
+ // §F1 event_publish_latency_ms — wall-clock delta (injected clock) around the persist transaction,
+ // written by the bus on publish. Nullable/ADDITIVE (§H): pre-latency rows read null and are excluded
+ // from the Observability percentile samples.
+ publish_latency_ms: integer(),
+ },
+ (table) => [
+ // §A3 幂等: storage-enforced dedupe. A re-publish with the same key hits this constraint → no-op.
+ uniqueIndex("deepagent_event_idempotency_idx").on(table.idempotency_key),
+ // §A4 去重窗口 + §F2 trace: scan same-type recent events (10s dedupe) and follow correlation chains.
+ index("deepagent_event_type_created_idx").on(table.type, table.created_at),
+ index("deepagent_event_correlation_idx").on(table.correlation_id, table.created_at),
+ // §A3 保留期: workspace-scoped retention sweep (default 30 天, per-workspace configurable).
+ index("deepagent_event_workspace_created_idx").on(table.workspace_id, table.created_at),
+ ],
+)
+
+// §A3 delivery/retry state — one row per (event, subscription group) delivery attempt tracker. Kept
+// separate from the immutable event log so retry bookkeeping never mutates the audit record. `status`
+// drives the retry loop; `attempts` backs the exponential-backoff schedule; a terminal failure flips
+// `status` to `dead` and the event surfaces in the DLQ view.
+export const DeepAgentEventDeliveryTable = sqliteTable(
+ "deepagent_event_delivery",
+ {
+ event_id: text()
+ .$type()
+ .notNull()
+ .references(() => DeepAgentEventTable.id, { onDelete: "cascade" }),
+ subscription_group: text().notNull(),
+ // pending → delivered | dead. `pending` rows with next_attempt_at <= now are eligible for retry.
+ status: text().$type<"pending" | "delivered" | "dead">().notNull(),
+ attempts: integer().notNull(),
+ last_error: text(),
+ next_attempt_at: integer(),
+ created_at: integer().notNull(),
+ updated_at: integer().notNull(),
+ },
+ (table) => [
+ // one delivery tracker per (event, group).
+ uniqueIndex("deepagent_event_delivery_unique_idx").on(table.event_id, table.subscription_group),
+ // retry scan: pending rows whose backoff has elapsed, oldest first.
+ index("deepagent_event_delivery_due_idx").on(table.status, table.next_attempt_at),
+ ],
+)
+
+// §A4 event_dropped — the durable DROP LOG. One append-only row per event the router shed (a §A4
+// backpressure drop), so Observability can aggregate `event_dropped_total` by reason exactly the way
+// `dlq_events_total` aggregates dead deliveries. Kept SEPARATE from the delivery table (a drop is not a
+// delivery attempt) and NOT FK-cascaded to the event log: a drop is an audit counter that must survive
+// the retention sweep of the event it references (the count is the signal, the row is the evidence).
+export const DeepAgentEventDropTable = sqliteTable(
+ "deepagent_event_drop",
+ {
+ id: integer().primaryKey({ autoIncrement: true }),
+ // the dropped event's id (for the §F2 trace) — plain column, no FK (see above).
+ event_id: text().$type().notNull(),
+ workspace_id: text().notNull(),
+ // why it was dropped — mirrors EventRouter.DropReason (currently "backpressure"; kept open for
+ // future reasons so the metric is decomposable by reason).
+ reason: text().notNull(),
+ // the event's priority at drop time (a low/normal shed under §A4 回压) — useful for the trace.
+ priority: text().$type().notNull(),
+ created_at: integer().notNull(),
+ },
+ (table) => [
+ // workspace-scoped, windowed aggregation for the §F1 event_dropped_total metric.
+ index("deepagent_event_drop_workspace_created_idx").on(table.workspace_id, table.created_at),
+ // §A4 DISTINCT-event semantics: one logical event may be shed→nacked→re-shed multiple times on the
+ // backpressure retry path, but event_dropped_total must count DISTINCT events shed, not shed-ATTEMPTS.
+ // UNIQUE on event_id (alone) → at most one drop row per event ever → recordDrop is idempotent per event
+ // (onConflictDoNothing), so COUNT(*) == distinct events shed. Unique on event_id (not event_id+reason)
+ // is deliberate: a given event is shed for one reason (§A4 backpressure); the first drop is the signal.
+ uniqueIndex("deepagent_event_drop_event_id_idx").on(table.event_id),
+ ],
+)
diff --git a/packages/core/src/deepagent/deepagent-event.ts b/packages/core/src/deepagent/deepagent-event.ts
new file mode 100644
index 00000000..0c6d1bb8
--- /dev/null
+++ b/packages/core/src/deepagent/deepagent-event.ts
@@ -0,0 +1,86 @@
+export * as DeepAgentEvent from "./deepagent-event"
+
+import { Schema } from "effect"
+import { externalID, type ExternalID, withStatics } from "../schema"
+import { Identifier } from "../util/identifier"
+
+// V4.0 §A1 — the DeepAgent event model. This is the WIRE + PERSISTENCE envelope every V4.0 trigger
+// (IM message, git push, CI failure, PR comment, monitor alert, scheduled scan, agent coordination)
+// is normalized into before it enters the Event Bus. It is DELIBERATELY distinct from the lower-level
+// `EventV2` sync-log envelope (core/src/event.ts): EventV2 is the durable per-aggregate append-only
+// substrate this bus is BUILT ON; `DeepAgentEvent` is the higher-level domain event carried inside an
+// EventV2 aggregate. See deepagent-event-bus.ts for how the two compose.
+//
+// LAYERING: lives in `core` — pure schema + types only, no LSP / panel / task-tool / session imports.
+// Every V4.0 event source produces one of these; the Router (deepagent-code) dispatches on `type`.
+
+// §A1 — a stable, sortable event id. `evt_` prefix mirrors EventV2.ID; ascending-monotonic component
+// keeps natural insertion order for debugging + dedupe-window scans.
+export const ID = Schema.String.check(Schema.isStartsWith("dae_")).pipe(
+ Schema.brand("DeepAgentEvent.ID"),
+ withStatics((schema) => ({
+ // `at` (an injected clock) keeps the id's monotonic time component aligned with the event's
+ // `createdAt`, so id-ascending order == createdAt order even under a deterministic test clock
+ // (see deepagent-event-bus.ts replay/recentByType, which tiebreak equal createdAt by id).
+ create: (at?: number) => schema.make("dae_" + Identifier.create(false, at)),
+ fromExternal: (input: ExternalID) => schema.make(externalID("dae", input)),
+ })),
+)
+export type ID = typeof ID.Type
+
+// §A1 event source — the origin system. Determines the default Agent (§A1 table) and the trust tier
+// checked in §E1 layer-1 ("event source 是否可信").
+export const EventSource = Schema.Literals(["im", "git", "ci", "pr", "monitor", "schedule", "system"])
+export type EventSource = Schema.Schema.Type
+
+// §A4 priority — Router uses this for preemption (critical 抢占低优队列) and backpressure (回压时拒绝
+// 低优事件). Also gates §E4 quiet-hours pass-through (high/critical 可穿透静默时段).
+export const EventPriority = Schema.Literals(["low", "normal", "high", "critical"])
+export type EventPriority = Schema.Schema.Type
+
+// §A1 — the canonical event envelope. `payload` is left as Unknown at the schema boundary because
+// event types are open/extensible; producers/consumers narrow it per `type`. The correlation/causation
+// pair (§F2 trace) strings an event to its cause and its emitted follow-ups.
+export const Event = Schema.Struct({
+ id: ID,
+ type: Schema.String, // e.g. "im.message.created", "ci.failure", "goal.tick"
+ source: EventSource,
+ workspaceID: Schema.String,
+ projectID: Schema.optional(Schema.String),
+ actorID: Schema.optional(Schema.String),
+ correlationID: Schema.optional(Schema.String), // §A3 顺序: same correlationID keeps causal order
+ causationID: Schema.optional(Schema.String), // the event that directly caused this one
+ idempotencyKey: Schema.String, // §A3 幂等: consumer dedupes on this
+ priority: EventPriority,
+ createdAt: Schema.Int,
+ payload: Schema.Unknown,
+}).annotate({ identifier: "DeepAgentEvent" })
+export type Event = Schema.Schema.Type
+
+// §C4 — inter-agent coordination events. Agents communicate THROUGH the bus, never by calling each
+// other's internal functions. These ride as `DeepAgentEvent.payload` under `type` = the tag below.
+export const AgentCoordinationEvent = Schema.Union([
+ Schema.Struct({ type: Schema.Literal("agent.task.started"), taskID: Schema.String, agentID: Schema.String }),
+ Schema.Struct({ type: Schema.Literal("agent.task.blocked"), taskID: Schema.String, reason: Schema.String }),
+ Schema.Struct({
+ type: Schema.Literal("agent.task.completed"),
+ taskID: Schema.String,
+ artifacts: Schema.Array(Schema.String),
+ }),
+]).annotate({ identifier: "AgentCoordinationEvent" })
+export type AgentCoordinationEvent = Schema.Schema.Type
+
+// The subset of §A1 fields a producer supplies; the bus fills id/createdAt/idempotencyKey defaults.
+export const PublishInput = Schema.Struct({
+ type: Schema.String,
+ source: EventSource,
+ workspaceID: Schema.String,
+ projectID: Schema.optional(Schema.String),
+ actorID: Schema.optional(Schema.String),
+ correlationID: Schema.optional(Schema.String),
+ causationID: Schema.optional(Schema.String),
+ idempotencyKey: Schema.optional(Schema.String),
+ priority: Schema.optional(EventPriority),
+ payload: Schema.Unknown,
+}).annotate({ identifier: "DeepAgentEvent.PublishInput" })
+export type PublishInput = Schema.Schema.Type
diff --git a/packages/core/src/deepagent/document-store.ts b/packages/core/src/deepagent/document-store.ts
index be051e00..4ffc607a 100644
--- a/packages/core/src/deepagent/document-store.ts
+++ b/packages/core/src/deepagent/document-store.ts
@@ -49,11 +49,21 @@ export type DocType =
// for integrity (INV-2). body carries path/language/symbol/signature; an optional content sha in
// extensions is for change-detection only, never identity/dedup. The lightweight indexer that
// registers these nodes is Phase 3's concern — this union entry is the only Phase-0 change.
- // ⚠ Phase 3 TODO (v3.8.1 §B.3 version-bloat tradeoff): upsert()/update() bump version+1 and write
- // a supersede link on every fingerprint change (INV-4, append-only). A frequently-edited code
- // base makes code_symbol version files grow linearly. Phase 3's indexer must decide the mitigation
- // (mtime-batched rate-limited rebuild, or relax append-only to in-place overwrite for code_symbol
- // since code nodes are derived data with no audit value). NOT relaxed here — left as a marker.
+ // version-bloat tradeoff (v3.8.1 §B.3): upsert()/update() bump version+1 and write a supersede link
+ // on every fingerprint change (INV-4, append-only). A frequently-edited code base makes code_symbol
+ // version files grow linearly.
+ // ⚠ T4.2 EVALUATION (V4.1): bloat is now bounded on TWO layers before it reaches here —
+ // (1) content-sha gating in code-indexer.registerFile writes ZERO new versions for an unchanged
+ // file (a re-index of an unchanged tree bumps nothing), and
+ // (2) T4.1's mtime gate in code-index-trigger skips the read+hash of an mtime-unchanged file, so
+ // an unchanged file never even reaches registerFile.
+ // So a version is created ONLY on a genuine content change — which is semantically correct
+ // versioning, one version per real edit. The residual (a single file edited many times across a
+ // long session accumulates that many versions) is low-severity derived data. DECISION: do NOT relax
+ // the append-only invariant for code_symbol here — the in-place-overwrite option would carve a
+ // special case into a load-bearing store invariant (INV-4) for a bounded, cosmetic cost. If disk
+ // growth ever becomes real, prefer a periodic retention sweep of superseded code_symbol versions
+ // (external to the store's write path) over relaxing append-only. Left as a marker, not relaxed.
| "code_symbol"
// ledger (v3.8.0 App-A §C2 Session Ledger): the session's structured, incrementally-maintained
// authoritative fact ledger (entries {kind: goal|constraint|decision|done|open|next|artifact,
@@ -419,6 +429,7 @@ export class DocumentStore {
const out: { rel: LinkRel; from: DocRef }[] = []
for (const [, versions] of this.docs) {
const latest = versions.get(Math.max(...versions.keys()))!
+ if (latest.scope === "sealed") continue // INV-7: sealed never surfaced (mirror list())
for (const l of latest.links) if (l.to === id) out.push({ rel: l.rel, from: toRef(latest) })
}
return out
@@ -454,7 +465,8 @@ export class DocumentStore {
if (!rels.includes(l.rel) || seen.has(l.to)) continue
seen.add(l.to)
const td = this.get(l.to)
- if (td) {
+ if (td && td.scope !== "sealed") {
+ // INV-7: a sealed doc is never surfaced via a graph edge, nor traversed through (mirror list()).
result.push(toRef(td))
next.push(l.to)
}
@@ -501,8 +513,14 @@ export class DocumentStore {
const typeDir = path.join(docsDir, entry.name)
for (const file of readdirSync(typeDir)) {
if (!file.endsWith(".json")) continue
- const doc = JSON.parse(readFileSync(path.join(typeDir, file), "utf8")) as Doc
- this.indexDoc(doc)
+ // A truncated/partial-write .json doc must not brick store construction (the files are the
+ // truth, but one corrupt file is not the whole truth). Skip it and index the rest.
+ try {
+ const doc = JSON.parse(readFileSync(path.join(typeDir, file), "utf8")) as Doc
+ this.indexDoc(doc)
+ } catch (error) {
+ console.warn(`deepagent document-store: skipping unreadable doc file ${path.join(typeDir, file)}`, error)
+ }
}
}
}
diff --git a/packages/core/src/deepagent/event-router.ts b/packages/core/src/deepagent/event-router.ts
new file mode 100644
index 00000000..e44b9999
--- /dev/null
+++ b/packages/core/src/deepagent/event-router.ts
@@ -0,0 +1,172 @@
+export * as EventRouter from "./event-router"
+
+import { DeepAgentEvent } from "./deepagent-event"
+import { LMNEvents } from "./lmn-events"
+import type { AgentDescriptor } from "../im/mention-parser"
+
+// V4.0 §A4 — the Event Router POLICY. This is a PURE, deterministic decision function: given an event,
+// the candidate agent registry projection, the current queue pressure, and the recent same-type events
+// (the §A4 去重窗口 primitive from the Event Bus), it decides whether the event dispatches (and to
+// which agents, at what priority) or is dropped (and why).
+//
+// LAYERING: lives in `core` and imports NOTHING runtime. Feature-flag state and per-agent permission
+// are NOT read here — they are resolved by the deepagent-code wiring and passed in as `flagEnabled` /
+// pre-filtered `agents`, so this module stays a pure, unit-testable policy with no Effect, no DB, no
+// RuntimeFlags import. The wiring subscribes to the bus, computes the gates, calls `route`, and
+// dispatches the decision to sessions/agents.
+//
+// §A4 Router responsibilities, mapped to this function:
+// 事件类型匹配 : match candidate agents by their `triggers[].event` (glob-ish, see `matches`).
+// 权限/flag 检查 : `flagEnabled` gate (resolved upstream) + `agents` already permission-filtered.
+// 去重 : within `dedupeWindowMs`, a duplicate LOW-priority same-type event is merged.
+// 优先级 : the decision carries the event's priority so the scheduler can preempt low queues.
+// 回压 : when the queue is at/over capacity, low/normal events are dropped (event_dropped);
+// high/critical always admitted (critical 抢占低优队列).
+
+// Priority ordering for preemption + admission decisions. Higher = more urgent.
+export const PRIORITY_RANK: Record = {
+ low: 0,
+ normal: 1,
+ high: 2,
+ critical: 3,
+}
+
+// Why an event was dropped rather than dispatched — surfaced as an `event_dropped` observability signal.
+// `coordination`: the event is a §C4 inter-agent coordination/derivative signal (agent.task.* /
+// agent.handoff.*) — it exists for observation/oversight/trace, NOT to trigger a fresh agent dispatch.
+// `operational`: the event is a §A3 OPERATIONAL alert (dlq.alert) — a dead-letter notification for
+// Oversight/SupervisorNotifier, NOT agent work; it must never be agent-dispatched (nor spin a
+// no_capable_agent nack loop if an operator happens to register a broad-glob trigger agent).
+export type DropReason = "flag_disabled" | "no_match" | "deduped" | "backpressure" | "coordination" | "operational"
+
+// §A3 OPERATIONAL SIGNAL family — events that are workspace-operator notifications (dead-letter alerts),
+// NOT agent-dispatch triggers. lmn-events.ts is explicit that dlq.alert is "a SYSTEM observation signal —
+// NOT an agent-dispatch trigger". Like the §C4 coordination guard this severs the AGENT-DISPATCH path only:
+// the event is still persisted + delivered to the trace/oversight/notify consumers (separate subscribers).
+// Guarding it in the PURE router (rather than relying on "no agent happens to subscribe") makes it robust
+// to an operator registering a broad-glob (`*` / `dlq.*`) trigger agent — such an agent can never grab an
+// operational alert, and the alert is terminal-acked (never an infinite no_capable_agent nack loop).
+export const OPERATIONAL_EVENT_TYPES: ReadonlySet = new Set([LMNEvents.DLQ_ALERT])
+export const isOperationalEvent = (eventType: string): boolean => OPERATIONAL_EVENT_TYPES.has(eventType)
+
+// §C4 RE-ENTRANCY GUARD — the coordination/derivative event-type family. The Multi-Agent Runtime emits
+// these BACK onto the bus (agent.task.started/blocked/completed/needs_human) as a side effect of a
+// `coordinate()` pass, so a broad-glob agent trigger (`agent.*` / `*`) subscribed to them would re-enter
+// the dispatcher → a new `coordinate()` → fresh coordination events (new ids, so the alreadyCompleted
+// guard never fires) → an unbounded, ceiling-bypassing cascade. Per §C4 these events are for
+// observation/oversight/trace only; they must NEVER re-trigger agent dispatch. They are still persisted
+// and delivered to the trace/oversight consumers (separate subscribers) — this only closes the
+// AGENT-DISPATCH loop. NOTE: `agent.push.*` (proactive push) is a DIFFERENT family and still routes.
+export const COORDINATION_EVENT_PREFIXES = ["agent.task."] as const
+export const isCoordinationEvent = (eventType: string): boolean =>
+ COORDINATION_EVENT_PREFIXES.some((prefix) => eventType.startsWith(prefix))
+
+export type RouteDecision =
+ | {
+ readonly type: "dispatch"
+ readonly priority: DeepAgentEvent.EventPriority
+ // the agents whose triggers matched the event, in registry order.
+ readonly targets: ReadonlyArray
+ }
+ | {
+ readonly type: "dropped"
+ readonly reason: DropReason
+ // for `deduped`: the id of the recent event this one merged into (for the trace).
+ readonly mergedInto?: DeepAgentEvent.ID
+ }
+
+export interface RouteInput {
+ readonly event: DeepAgentEvent.Event
+ // registry projection, ALREADY permission-filtered by the caller (only agents allowed to see this
+ // workspace/project/event). The router matches on `triggers` within this set.
+ readonly agents: ReadonlyArray
+ // resolved feature-flag gate for this event's path (e.g. v4EventDrivenIm for im.*). A disabled flag
+ // drops the event fail-closed — the legacy synchronous path stays authoritative.
+ readonly flagEnabled: boolean
+ // current depth of the dispatch queue and its capacity (回压). Omit `maxQueueDepth` for no limit.
+ readonly queueDepth?: number
+ readonly maxQueueDepth?: number
+ // recent same-type events (bus.recentByType, ordered most-recent-first) for the §A4 去重窗口 merge.
+ // MUST already be scoped to this event's workspace by the caller (the router does NOT re-check
+ // workspace — an unscoped set risks a cross-tenant merge). Since routing runs post-persist this set
+ // typically INCLUDES the event itself; `route` filters it out defensively.
+ readonly recentSameType?: ReadonlyArray
+}
+
+// Does an agent trigger match an event type? Supports an exact match and a trailing `*` wildcard
+// (e.g. `agent.*` matches `agent.task.started`). Kept intentionally small; richer `match` conditions
+// on the Trigger are a forward-compat declaration (mention-parser.ts) and not evaluated here yet.
+export const matches = (triggerEvent: string, eventType: string): boolean => {
+ if (triggerEvent === eventType) return true
+ if (triggerEvent === "*") return true
+ if (triggerEvent.endsWith(".*")) {
+ const prefix = triggerEvent.slice(0, -1) // keep the trailing dot: "agent." matches "agent.x"
+ return eventType.startsWith(prefix)
+ }
+ return false
+}
+
+// The candidate agents whose triggers match this event, preserving registry order and de-duplicating.
+const matchingAgents = (
+ agents: ReadonlyArray,
+ eventType: string,
+): ReadonlyArray =>
+ agents.filter((agent) => (agent.triggers ?? []).some((t) => matches(t.event, eventType)))
+
+/**
+ * §A4 — the pure routing decision. Order of checks (fail-closed first):
+ * 0a. coordination → `coordination` if the event is a §C4 derivative signal (never re-dispatches).
+ * 0b. operational → `operational` if the event is a §A3 dlq.alert (an operator notice, never agent work).
+ * 1. flag gate → `flag_disabled` if the event path's flag is off.
+ * 2. type match → `no_match` if no permitted agent subscribes to this type.
+ * 3. dedup (低优) → `deduped` if a low-priority same-type event already exists in the window.
+ * 4. backpressure → `backpressure` if the queue is full and this event is low/normal.
+ * 5. otherwise → `dispatch` to the matched agents at the event's priority.
+ *
+ * Dedup only merges LOW priority (the §A4 contract: "同类重复低优事件合并") — normal/high/critical are
+ * never silently merged. Backpressure never drops high/critical (critical 抢占低优队列).
+ */
+export const route = (input: RouteInput): RouteDecision => {
+ // §C4 RE-ENTRANCY GUARD (first, before agent matching): coordination/derivative events NEVER trigger a
+ // fresh agent dispatch, even if a wildcard-trigger agent (`agent.*` / `*`) would otherwise match them.
+ // This is the loop-closer — without it, coordinate()'s own emitted events (new ids each pass) would
+ // re-enter and cascade unbounded, bypassing the §E2 ceiling. Checked before the flag gate so it holds
+ // regardless of which flag governs the event path. The event is still persisted + observable by the
+ // trace/oversight consumers; only the agent-dispatch path is severed here.
+ if (isCoordinationEvent(input.event.type)) return { type: "dropped", reason: "coordination" }
+
+ // §A3 OPERATIONAL guard (also before agent matching + the flag gate): a dlq.alert is an operator
+ // notification, never agent work. Severs the agent-dispatch path so a broad-glob trigger agent can't
+ // grab it; the alert is still observable + delivered to the SupervisorNotifier/Oversight consumers.
+ if (isOperationalEvent(input.event.type)) return { type: "dropped", reason: "operational" }
+
+ if (!input.flagEnabled) return { type: "dropped", reason: "flag_disabled" }
+
+ const targets = matchingAgents(input.agents, input.event.type)
+ if (targets.length === 0) return { type: "dropped", reason: "no_match" }
+
+ const priority = input.event.priority
+
+ // §A4 去重窗口: only LOW-priority duplicates merge. `recentSameType` is caller-scoped to the same
+ // type + workspace + window; the first recent event (most recent) is the merge target.
+ if (priority === "low") {
+ const recent = input.recentSameType ?? []
+ const target = recent.find((e) => e.id !== input.event.id)
+ if (target) return { type: "dropped", reason: "deduped", mergedInto: target.id }
+ }
+
+ // §A4 回压: reject low/normal when the queue is at/over capacity; high/critical always pass. A
+ // non-positive `maxQueueDepth` is treated as "no limit" (not "always full") — a 0/negative capacity
+ // that silently dropped every low/normal event would be a footgun; omit the field or pass a positive
+ // cap to enable backpressure.
+ if (
+ input.maxQueueDepth != null &&
+ input.maxQueueDepth > 0 &&
+ (input.queueDepth ?? 0) >= input.maxQueueDepth &&
+ PRIORITY_RANK[priority] < PRIORITY_RANK.high
+ ) {
+ return { type: "dropped", reason: "backpressure" }
+ }
+
+ return { type: "dispatch", priority, targets }
+}
diff --git a/packages/core/src/deepagent/goal-loop.ts b/packages/core/src/deepagent/goal-loop.ts
index 1729a922..23b40590 100644
--- a/packages/core/src/deepagent/goal-loop.ts
+++ b/packages/core/src/deepagent/goal-loop.ts
@@ -3,7 +3,10 @@ import { randomUUID } from "node:crypto"
import type { DocumentStore } from "./document-store"
import {
type PlanDoc,
+ type PlanInput,
buildCompletionReport,
+ buildPlanFromInput,
+ hasBlockedSteps,
planScope,
} from "./plan-controller"
@@ -132,8 +135,15 @@ export class InvalidGoalError extends Schema.TaggedErrorClass(
export type GraderPorts = {
/** Run the given validation commands; `pass` iff ALL succeeded. */
readonly runTests: (commands: readonly string[]) => Effect.Effect<{ readonly pass: boolean }>
- /** Highest diagnostic severity currently present, or null when there are none. */
- readonly diagnostics: () => Effect.Effect<{ readonly maxSeverity: string | null }>
+ /**
+ * Highest diagnostic severity currently present, or null when there are none. `checked` MUST be false
+ * when the port could not actually compute diagnostics (LSP crashed/timed out, no client covered the
+ * changed files, defect fallback). A `maxSeverity: null, checked: false` result means "unknown", NOT
+ * "clean" — the grader treats an unchecked result as an unmet gap (mirrors runTests' empty-set = NOT
+ * passed), so a broken/absent LSP can never vacuously satisfy `no_diagnostics`. `checked` defaults to
+ * true only for an explicit, truthful result. See goal-loop-wiring.ts.
+ */
+ readonly diagnostics: () => Effect.Effect<{ readonly maxSeverity: string | null; readonly checked?: boolean }>
/** Reviewer subagent verdict: `pass` iff no finding exceeds `maxSeverity`. */
readonly reviewerClean: (maxSeverity: string) => Effect.Effect<{ readonly pass: boolean }>
/** Expert Panel verdict (§D.7 key decision point) — `decision` is approve/revise/block/needs_human. */
@@ -172,8 +182,12 @@ const evaluateOne = (
return pass ? null : `tests_pass: one or more of [${criterion.commands.join(", ")}] failed`
}
case "no_diagnostics": {
- const { maxSeverity } = yield* ports.diagnostics()
- if (maxSeverity == null) return null // no diagnostics at all → always met
+ const { maxSeverity, checked } = yield* ports.diagnostics()
+ // A port that could NOT actually check (LSP down/timeout, no client for the changed files,
+ // defect fallback) reports checked:false. That is UNKNOWN, not clean — treat it as an unmet gap
+ // so a broken/absent LSP never vacuously satisfies no_diagnostics (mirrors runTests empty = fail).
+ if (checked === false) return "no_diagnostics: could not verify diagnostics (no diagnostic provider reported)"
+ if (maxSeverity == null) return null // genuinely checked and no diagnostics at all → met
// With no severityAtMost, ANY diagnostic is a gap (strict "no diagnostics"). With a bound, a
// diagnostic is a gap only when it is strictly more severe than the allowed ceiling.
if (criterion.severityAtMost == null)
@@ -193,6 +207,13 @@ const evaluateOne = (
case "plan_complete": {
if (plan == null) return "plan_complete: plan document not found"
const report = buildCompletionReport(plan)
+ // U10 parity with plan-controller finalize: buildCompletionReport counts a `blocked` step as
+ // RESOLVED (so finalize isn't deadlocked behind an escape hatch), which makes report.complete
+ // true when every step is done-or-blocked. But a blocked step never finishes on its own — the
+ // goal is NOT cleanly complete, it needs a human. Guard blocked steps explicitly here rather
+ // than trusting report.complete, so a blocked plan surfaces a gap instead of a silent "done".
+ if (hasBlockedSteps(plan))
+ return `plan_complete: blocked steps need a human [${report.blocked.join(", ")}]`
return report.complete
? null
: `plan_complete: outstanding steps [${report.outstanding.join(", ")}]`
@@ -215,41 +236,6 @@ const CRITERION_COST_RANK: Record = {
const isExpensiveCriterion = (kind: CompletionCriterion["kind"]): boolean =>
kind === "reviewer_clean" || kind === "panel_approves"
-/**
- * §D.3 Grader.evaluate — ALL criteria must be met (AND). `gaps` lists every unmet criterion. Pure with
- * respect to its ports: same ports + same plan → same result.
- *
- * With `{ deferExpensive: true }` (the Controller default) the criteria are evaluated cheap-first and
- * the SUBAGENT-SPAWNING gates (reviewer_clean, panel_approves) are SKIPPED once any cheaper criterion is
- * already unmet — the goal cannot be `met` this tick regardless, so spending a panel/reviewer turn to
- * enumerate a gap we will not act on is pure waste (§D.7 非每轮). This never changes the met/unmet
- * verdict (a cheap gap already forces met=false); it only avoids convening the panel except at the key
- * decision point when everything cheaper passes. Direct callers (tests) default to the full evaluation.
- */
-export const evaluateCriteria = (
- criteria: readonly CompletionCriterion[],
- ports: GraderPorts,
- plan: PlanDoc | null,
- options: { readonly deferExpensive?: boolean } = {},
-): Effect.Effect =>
- Effect.gen(function* () {
- const deferExpensive = options.deferExpensive ?? false
- const ordered = deferExpensive
- ? [...criteria].sort((a, b) => CRITERION_COST_RANK[a.kind] - CRITERION_COST_RANK[b.kind])
- : criteria
- const gaps: string[] = []
- for (const c of ordered) {
- // Defer the expensive, subagent-spawning gates until every cheaper criterion has passed.
- if (deferExpensive && gaps.length > 0 && isExpensiveCriterion(c.kind)) {
- gaps.push(`${c.kind}: deferred — a cheaper criterion is unmet (panel/reviewer not convened this tick)`)
- continue
- }
- const gap = yield* evaluateOne(c, ports, plan)
- if (gap != null) gaps.push(gap)
- }
- return { met: gaps.length === 0, gaps }
- })
-
// The Controller's view of grading: the spec GraderResult PLUS whether a gate ACTIVELY rejected (as
// opposed to merely being unmet). A panel `block` / `needs_human` verdict is an active rejection — the
// panel is telling us to stop and get a human, not "try again" — so the loop escalates on the FIRST
@@ -262,9 +248,15 @@ export type GraderDecision = {
readonly escalateReason: string | null
}
-// Evaluate for the Controller: same AND-gate + cheap-first deferral as evaluateCriteria, but also flags
-// an active panel rejection for immediate escalation. Only `panel_approves` can escalate (the panel is
-// the human-in-the-loop decision point, §D.7); a `revise` verdict is a soft gap.
+// Evaluate for the Controller — the sole grader entry point. ALL criteria must be met (AND); `gaps`
+// lists every unmet criterion. Pure w.r.t. its ports: same ports + same plan → same result. Criteria
+// are ordered cheap-first and the SUBAGENT-SPAWNING gates (reviewer_clean, panel_approves) are SKIPPED
+// once any cheaper criterion is already unmet — the goal cannot be `met` this tick regardless, so
+// spending a panel/reviewer turn to enumerate a gap we will not act on is pure waste (§D.7 非每轮).
+// This never changes the met/unmet verdict; it only avoids convening the panel except at the key
+// decision point when everything cheaper passes. It ALSO flags an active panel rejection for immediate
+// escalation: only `panel_approves` can escalate (the human-in-the-loop decision point, §D.7); a
+// `revise` verdict stays a soft gap.
export const evaluateForController = (
criteria: readonly CompletionCriterion[],
ports: GraderPorts,
@@ -293,12 +285,18 @@ export const evaluateForController = (
}
const gap = yield* evaluateOne(c, ports, plan)
if (gap != null) gaps.push(gap)
+ // U10 parity: a blocked plan step is an ACTIVE rejection, not a soft gap — the step will never
+ // finish on its own, so route to a human on the FIRST such verdict rather than burning ticks
+ // re-executing an unadvanceable plan until the stall threshold. Mirrors plan-controller finalize
+ // (hasBlockedSteps → needs_human).
+ if (c.kind === "plan_complete" && hasBlockedSteps(plan)) {
+ escalate = true
+ escalateReason = "plan has blocked steps"
+ }
}
return { result: { met: gaps.length === 0, gaps }, escalate, escalateReason }
})
-export const Grader = { evaluate: evaluateCriteria, evaluateForController }
-
// ---------------------------------------------------------------------------------------------------
// Controller — the tick state machine. State lives entirely in a run_context doc so a restart recovers.
// ---------------------------------------------------------------------------------------------------
@@ -309,6 +307,13 @@ export type StepExecutorResult = {
readonly cost?: number
/** A critical / unrecoverable failure — the tick rolls back rather than continuing. */
readonly critical?: boolean
+ /**
+ * The session the executor actually RAN the step in. The Controller runs in a parent goal session but
+ * the executor may run each turn in a fresh CHILD session (that is where the file mutations live). On a
+ * critical failure the Controller must roll back THAT session, not the parent (which has no edits) — so
+ * the executor surfaces it here. Absent ⇒ the executor ran in the goal session itself.
+ */
+ readonly executedSessionId?: string
}
/**
@@ -323,7 +328,13 @@ export type StepExecutor = (input: {
readonly activeStepId: string | null
}) => Effect.Effect
-/** §D.6 可回滚: injected rollback (production wires the existing revert). Best-effort, never fatal. */
+/**
+ * §D.6 可回滚: injected rollback (production wires the existing revert). Best-effort, never fatal.
+ * `sessionId` is the session whose changes must be reverted — the executor's ACTUAL run session (a child
+ * session where the edits live) when it reported one, else the goal session. Reverting the wrong session
+ * (e.g. the parent, which has no edits) makes `rolled_back` reported-but-false, leaving the workspace
+ * dirty after a critical failure.
+ */
export type RollbackPort = (input: {
readonly goalId: string
readonly sessionId: string
@@ -559,6 +570,21 @@ export interface GoalLoop {
readonly tick: (handle: GoalHandle) => Effect.Effect
readonly status: (handle: GoalHandle) => Effect.Effect
readonly stop: (handle: GoalHandle) => Effect.Effect
+ /**
+ * V4.1 §S2 — apply a USER plan edit to a running/paused goal and RE-BASELINE stall/progress tracking.
+ * Called by the driver BETWEEN ticks (never mid-tick), using the driver's OWN store handle — that is
+ * why the edit is observed on the next tick (a separate store handle from an HTTP fiber would NOT be,
+ * since DocumentStore serves reads from an in-memory map rebuilt only at construction). It:
+ * 1. upserts the plan doc with the edited PlanDoc (version+1 — so the next tick's version-dedup does
+ * NOT skip it; a fingerprint-identical no-op edit is a no-op upsert, harmless).
+ * 2. RE-BASELINES the persisted run_context state to the EDITED plan: stallCount→0 and
+ * lastDoneCount/lastEvidenceCount/lastMetCount/lastFingerprint set to the edited plan's values, and
+ * lastProcessedVersion→null (so the tick after the edit runs). Without this, a user re-opening a
+ * done step (done→pending) reads as a progress REGRESSION and could trip the stall-stop; the
+ * re-baseline gives the revision a fresh runway.
+ * No-op when no persisted state exists for the handle (goal not started / already gone).
+ */
+ readonly applyPlanEdit: (handle: GoalHandle, edit: PlanInput) => Effect.Effect
}
// §D.4 start validation — HARD, no defaults that bypass. criteria empty → not objectively decidable;
@@ -649,7 +675,32 @@ export const makeGoalLoop = (deps: ControllerDeps): GoalLoop => {
// dedup guarantees "a repeated no-progress tick has no side effects", not "every tick is exactly-
// once under a mid-tick crash". The driver must therefore treat tick as at-least-once.
if (state.lastProcessedVersion === version && state.lastOutcome != null) {
- return state.lastOutcome
+ // 幂等: a replay at the SAME version has NO side effects — never re-execute, never re-accrue
+ // budget (ticks/tokens/cost). BUT a replay at an unchanged version is by definition a
+ // NO-PROGRESS tick: if we just returned the recorded (non-terminal) outcome forever, an
+ // executor that RUNS but leaves the plan-doc unchanged (no version bump) would bypass the
+ // stall guard entirely — only the driver's maxIterations would ever stop it. So a NON-TERMINAL
+ // replay still accrues toward the stall guard here, and escalates to needs_human once the
+ // stall threshold is reached. (A terminal outcome was already replayed by the phase check
+ // above, so `lastOutcome` here is always the non-terminal `continue`; the guard is defensive.)
+ // Capture the null-checked outcome so the narrowing survives the `let state` reassignments below.
+ const replayOutcome: TickOutcome = state.lastOutcome
+ if (isTerminalPhase(phaseForOutcome(replayOutcome))) return replayOutcome
+ const stallCount = state.stallCount + 1
+ if (stallCount >= state.spec.stallThreshold) {
+ state = {
+ ...state,
+ phase: "needs_human",
+ stallCount,
+ lastOutcome: "needs_human",
+ gaps: [`no progress for ${stallCount} consecutive ticks (plan version unchanged)`, ...state.gaps],
+ }
+ persistState(deps, state)
+ return "needs_human"
+ }
+ state = { ...state, stallCount }
+ persistState(deps, state)
+ return replayOutcome
}
// 不越权: execute the active step via the injected executor (normal perms; Loop never elevates).
@@ -677,10 +728,13 @@ export const makeGoalLoop = (deps: ControllerDeps): GoalLoop => {
// is NaN, which would poison the ledger permanently and silently disable the token cap forever
// (`NaN > maxTokens` is always false). Coerce a non-finite / negative token count to 0.
const addTokens = Number.isFinite(execResult.tokensUsed) ? Math.max(0, Math.trunc(execResult.tokensUsed)) : 0
+ // Symmetric with tokens: a negative cost from a misbehaving port would DECREMENT the ledger (and
+ // could indefinitely defer the maxCost cap), so clamp to 0 just like the token count.
+ const addCost = Number.isFinite(execResult.cost) ? Math.max(0, execResult.cost as number) : 0
const ledger: BudgetLedger = {
ticks: state.ledger.ticks + 1,
tokens: state.ledger.tokens + addTokens,
- cost: state.ledger.cost + (Number.isFinite(execResult.cost) ? (execResult.cost as number) : 0),
+ cost: state.ledger.cost + addCost,
wallclockMs: Math.max(0, deps.now() - state.ledger.startedAtMs),
startedAtMs: state.ledger.startedAtMs,
}
@@ -756,10 +810,13 @@ export const makeGoalLoop = (deps: ControllerDeps): GoalLoop => {
)
// A DEFECT in the injected rollback port must not escape tick's never-fail contract — the audit
// trail above is already durable, so swallow a rollback defect (best-effort / never fatal).
+ // Revert the session the executor ACTUALLY ran the step in (the child session that holds the file
+ // mutations), falling back to the goal session only when the executor ran there directly. Passing
+ // the parent goal session here would revert nothing (it has no edits) — the wrong-session bug.
yield* deps
.rollback({
goalId: state.goalId,
- sessionId: state.sessionId,
+ sessionId: execResult.executedSessionId ?? state.sessionId,
reason: `critical failure at tick ${ledger.ticks}`,
})
.pipe(Effect.catchCause(() => Effect.void))
@@ -792,7 +849,48 @@ export const makeGoalLoop = (deps: ControllerDeps): GoalLoop => {
persistState(deps, { ...state, phase: "stopped", lastOutcome: state.lastOutcome })
})
- return { start, tick, status, stop }
+ const applyPlanEdit: GoalLoop["applyPlanEdit"] = (handle, edit) =>
+ Effect.sync(() => {
+ const state = loadState(deps, handle)
+ // No persisted state (goal not started / gone), or already terminal → nothing to edit/re-baseline.
+ if (state == null || isTerminalPhase(state.phase)) return
+ // Reconcile the user's revision against the CURRENT durable plan (via this driver's own store
+ // handle) so step ids + accumulated evidence are PRESERVED across the rewrite (buildPlanFromInput
+ // matches revised steps to prior steps by id). Reading `previous` here — not in the HTTP fiber —
+ // is why evidence isn't lost: the driver's handle holds the up-to-date doc incl. the last tick's
+ // mirrored-back progress.
+ const existing = deps.store.get(state.planDocId)
+ // The goal's plan doc must already exist (start materialized it). If it is somehow gone, do NOT
+ // fabricate an orphan under a fresh id — a re-baseline against a doc the tick can't read would be
+ // silent data loss. Bail (no-op) so the caller's ok:true never lies about a lost edit.
+ if (existing == null) return
+ const previous = readPlan(deps.store, state.planDocId).plan
+ const editedPlan = buildPlanFromInput(state.sessionId, edit, previous)
+ // 1) Write the edited plan back to THE SAME durable doc by id (version+1), so the next tick's
+ // readPlan(state.planDocId) sees the revision. Writing by id — not upsert-by-logical-key —
+ // avoids the description/idSlug matching that would otherwise mint an orphan doc (upsert's
+ // findLogical keys on description, which need not equal the doc materialize() wrote). A
+ // fingerprint-identical edit is a no-op (updateWithProvenance returns cur unchanged, INV-4).
+ // provenance.source="human" records the human-authored revision (vs the model's plan-tool edits).
+ deps.store.updateWithProvenance(state.planDocId, JSON.stringify(editedPlan), {
+ source: "human",
+ run_ref: planScope(state.sessionId),
+ })
+ // 2) Re-baseline: reset stall + set the progress baselines to the EDITED plan, and null the
+ // processed-version so the next tick runs against the revision (not deduped, not a false stall).
+ persistState(deps, {
+ ...state,
+ stallCount: 0,
+ lastFingerprint: planFingerprint(editedPlan),
+ lastDoneCount: doneStepCount(editedPlan),
+ lastEvidenceCount: evidenceCount(editedPlan),
+ // metCount is criteria-derived (independent of plan structure); leave it — a plan edit does not
+ // retroactively un-meet a satisfied criterion, and the next tick re-grades regardless.
+ lastProcessedVersion: null,
+ })
+ })
+
+ return { start, tick, status, stop, applyPlanEdit }
}
export * as GoalLoop from "./goal-loop"
diff --git a/packages/core/src/deepagent/graph-query.ts b/packages/core/src/deepagent/graph-query.ts
index 833369ed..950c98d3 100644
--- a/packages/core/src/deepagent/graph-query.ts
+++ b/packages/core/src/deepagent/graph-query.ts
@@ -131,6 +131,12 @@ const collectFromStore = (
const consider = (id: string, distance: number): boolean => {
const doc = ds.get(id)
if (!doc) return false
+ // INV-7: sealed docs never surface (list()/neighbors()/getRefsIn() all skip them). `consider` is also
+ // reached from the EXPLICIT-seed frontier (caller-supplied ids resolved via raw get()), so the seal
+ // filter must live HERE too — otherwise a sealed seed id leaks its body into graph-query / wiki
+ // traversal results. Mirrors the list() exclusion; latent today (no writer emits scope:"sealed") but
+ // this closes the accessor before any sealed-writer ships.
+ if (doc.scope === "sealed") return false
const existing = found.get(id)
if (existing && existing.distance <= distance) return false
found.set(id, { doc, distance })
diff --git a/packages/core/src/deepagent/human-takeover-sql.ts b/packages/core/src/deepagent/human-takeover-sql.ts
new file mode 100644
index 00000000..87441311
--- /dev/null
+++ b/packages/core/src/deepagent/human-takeover-sql.ts
@@ -0,0 +1,31 @@
+import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core"
+
+// V4.0 §D2/§F — durable HUMAN TAKEOVER log. A "takeover" is the moment a human steps IN over an agent:
+// pausing/reverting an agent's session, or claiming a branch/session an agent was driving. The §D2
+// Takeover surface needs a record of these so an operator can see WHEN and BY WHOM control was reclaimed,
+// and §F exposes the count as the `human_takeover_total` metric. One append-only row per takeover event —
+// this is an audit record (never mutated), kept separate from the Approval Queue (whose rows are mutable
+// pending→resolved decisions) because a takeover is an already-happened FACT, not a request awaiting one.
+export const HumanTakeoverTable = sqliteTable(
+ "deepagent_human_takeover",
+ {
+ id: text().primaryKey(),
+ workspace_id: text().notNull(),
+ // the session the human took over (an agent's session / a goal session). Optional so a branch-level
+ // takeover with no single session can still be recorded.
+ session_id: text(),
+ // the agent whose work was taken over, when known (the acting agent id).
+ agent_id: text(),
+ // the human actor who reclaimed control (routed workspace identity / user id).
+ actor_id: text(),
+ // a short, free-form reason ("paused", "reverted", "claimed_branch", …) for the §D2 surface.
+ reason: text(),
+ created_at: integer().notNull(),
+ },
+ (table) => [
+ // §F metric + §D2 surface: a workspace's takeovers over a window, newest first.
+ index("deepagent_human_takeover_workspace_idx").on(table.workspace_id, table.created_at),
+ ],
+)
+
+export * as HumanTakeoverSql from "./human-takeover-sql"
diff --git a/packages/core/src/deepagent/human-takeover.ts b/packages/core/src/deepagent/human-takeover.ts
new file mode 100644
index 00000000..2b4e3469
--- /dev/null
+++ b/packages/core/src/deepagent/human-takeover.ts
@@ -0,0 +1,126 @@
+export * as HumanTakeover from "./human-takeover"
+
+import { Context, Effect, Layer } from "effect"
+import { and, desc, eq, gte, lte, sql } from "drizzle-orm"
+import { Database } from "../database/database"
+import { HumanTakeoverTable } from "./human-takeover-sql"
+import { Identifier } from "../util/identifier"
+
+// V4.0 §D2/§F — the Human Takeover service. Records the FACT that a human stepped in over an agent
+// (paused/reverted its session, or claimed a branch/session it was driving) and exposes the count as the
+// §F `human_takeover_total` metric. This is the backend the §D2 Takeover surface (P3.12 frontend) calls
+// to record a takeover, and the observability metric reads. Append-only: a takeover is a past event, not
+// a mutable request — so there is no resolve/update, only record + count/list.
+//
+// LAYERING: `core`. Pure durable state; the HTTP/Oversight layer (deepagent-code) calls `record` from the
+// Takeover endpoint and `count` feeds the Observability metric snapshot.
+
+export interface TakeoverRecord {
+ readonly id: string
+ readonly workspaceID: string
+ readonly sessionID?: string
+ readonly agentID?: string
+ readonly actorID?: string
+ readonly reason?: string
+ readonly createdAt: number
+}
+
+export interface RecordInput {
+ readonly workspaceID: string
+ readonly sessionID?: string
+ readonly agentID?: string
+ readonly actorID?: string
+ readonly reason?: string
+}
+
+export interface Interface {
+ /** §D2 — record a human takeover (an already-happened fact). Returns the persisted row. */
+ readonly record: (input: RecordInput) => Effect.Effect
+ /** §D2 — a workspace's takeovers over [from, to], newest first (the Takeover surface). */
+ readonly list: (input: { workspaceID: string; from: number; to: number }) => Effect.Effect>
+ /** §F `human_takeover_total` — the count of takeovers for a workspace over [from, to]. */
+ readonly count: (input: { workspaceID: string; from: number; to: number }) => Effect.Effect
+}
+
+export class Service extends Context.Service()("@deepagent-code/HumanTakeover") {}
+
+export interface LayerOptions {
+ readonly now?: () => number
+}
+
+const decode = (row: {
+ id: string
+ workspace_id: string
+ session_id: string | null
+ agent_id: string | null
+ actor_id: string | null
+ reason: string | null
+ created_at: number
+}): TakeoverRecord => ({
+ id: row.id,
+ workspaceID: row.workspace_id,
+ ...(row.session_id != null ? { sessionID: row.session_id } : {}),
+ ...(row.agent_id != null ? { agentID: row.agent_id } : {}),
+ ...(row.actor_id != null ? { actorID: row.actor_id } : {}),
+ ...(row.reason != null ? { reason: row.reason } : {}),
+ createdAt: row.created_at,
+})
+
+export const layerWith = (options?: LayerOptions) =>
+ Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const { db } = yield* Database.Service
+ const now = options?.now ?? Date.now
+
+ const record: Interface["record"] = (input) =>
+ Effect.gen(function* () {
+ const row = {
+ id: "tko_" + Identifier.ascending(),
+ workspace_id: input.workspaceID,
+ session_id: input.sessionID ?? null,
+ agent_id: input.agentID ?? null,
+ actor_id: input.actorID ?? null,
+ reason: input.reason ?? null,
+ created_at: now(),
+ }
+ yield* db.insert(HumanTakeoverTable).values([row]).run().pipe(Effect.orDie)
+ return decode(row)
+ })
+
+ const list: Interface["list"] = (input) =>
+ db
+ .select()
+ .from(HumanTakeoverTable)
+ .where(
+ and(
+ eq(HumanTakeoverTable.workspace_id, input.workspaceID),
+ gte(HumanTakeoverTable.created_at, input.from),
+ lte(HumanTakeoverTable.created_at, input.to),
+ ),
+ )
+ .orderBy(desc(HumanTakeoverTable.created_at))
+ .all()
+ .pipe(Effect.orDie, Effect.map((rows) => rows.map(decode)))
+
+ const count: Interface["count"] = (input) =>
+ db
+ .select({ n: sql`count(*)` })
+ .from(HumanTakeoverTable)
+ .where(
+ and(
+ eq(HumanTakeoverTable.workspace_id, input.workspaceID),
+ gte(HumanTakeoverTable.created_at, input.from),
+ lte(HumanTakeoverTable.created_at, input.to),
+ ),
+ )
+ .get()
+ .pipe(Effect.orDie, Effect.map((r) => r?.n ?? 0))
+
+ return Service.of({ record, list, count })
+ }),
+ )
+
+export const layer = layerWith()
+
+export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
diff --git a/packages/core/src/deepagent/knowledge-source.ts b/packages/core/src/deepagent/knowledge-source.ts
index d26077ae..99314c85 100644
--- a/packages/core/src/deepagent/knowledge-source.ts
+++ b/packages/core/src/deepagent/knowledge-source.ts
@@ -33,6 +33,17 @@ export const configure = (dir: string): void => {
export const isConfigured = (): boolean => baseDir !== null
+// Reset to the unconfigured state (baseDir=null + caches cleared). `configure` is a process-global
+// setter with no other way back to null; tests that assert the UNCONFIGURED path (isConfigured()===false
+// → callers fall back to empty results) need this to guarantee their precondition regardless of a prior
+// test in the same process having called configure(). Not used by production wiring (the gateway only
+// ever configures forward).
+export const reset = (): void => {
+ baseDir = null
+ userGlobalCache = null
+ projectCache.clear()
+}
+
// Clear cached stores so a subsequent query re-reads from disk (after approve/reject/seed).
export const invalidateCache = (): void => {
userGlobalCache = null
diff --git a/packages/core/src/deepagent/lmn-events.ts b/packages/core/src/deepagent/lmn-events.ts
new file mode 100644
index 00000000..c7e688d9
--- /dev/null
+++ b/packages/core/src/deepagent/lmn-events.ts
@@ -0,0 +1,106 @@
+export * as LMNEvents from "./lmn-events"
+
+// V4.0 §L/§M/§N — the canonical DeepAgentEvent `type` strings for wiring the existing V3.9 bodies
+// (Repo & Wiki, Expert Panel, Goal Loop) onto the Event Bus. These are NOT new mechanics — the bodies
+// already exist (V3.9); this module just fixes the event vocabulary so the producers (session loop,
+// panel orchestrator, goal driver), the consumers (ExecutionArchiver, Oversight, IM push), and the
+// observability layer all agree on the same strings. Each rides as a DeepAgentEvent on the bus.
+//
+// LAYERING: `core`, constants only.
+
+// §L Repo & Wiki — the ExecutionArchiver consumes session/goal completion to archive execution traces
+// as Wiki pages, and they feed IM push notifications for supervisors.
+export const SESSION_COMPLETED = "session.completed"
+
+// §B IM — a user message, after it persists, publishes this (the §B1 double-write). The Router/
+// MentionAgent consume it; the legacy synchronous @mention path stays authoritative until the flag is on.
+export const IM_MESSAGE_CREATED = "im.message.created"
+
+// §A1 EXTERNAL INGRESS — the four external event sources (git hook/webhook, CI webhook, PR webhook,
+// observability/monitoring) named in the §A1 table. The webhook ingress (deepagent-code) authenticates
+// the caller and normalizes each delivery into a DeepAgentEvent carrying one of these exact `type`
+// strings — matching the tables the consumers already key on: EventRouter (agent `triggers[].event`),
+// TaskPartitioner.DEFAULT_RULES, and PanelConvenePolicy.DEFAULT_RULES. These are producer-side constants
+// so the ingress can never drift from the strings the router/partitioner/panel match on.
+//
+// §E1 TRUST: git/ci/pr/monitor are NOT in DEFAULT_TRUSTED_SOURCES (["im","system","schedule"]) — as of
+// P0.1 external sources are opt-in. The ingress still persists + traces these events, but the security
+// gate BLOCKS agent dispatch until an operator adds the source to the workspace's trustedSources. This
+// is intended (untrusted external input is fail-closed by default).
+export const GIT_PUSH = "git.push"
+export const CI_FAILURE = "ci.failure"
+export const PR_COMMENT = "pr.comment"
+export const MONITOR_ALERT = "monitor.alert"
+
+// §N Goal Loop — the tick is now an event (durable/retryable/dedup'd); terminal states go to Oversight.
+export const GOAL_TICK = "goal.tick"
+export const GOAL_COMPLETED = "goal.completed"
+export const GOAL_NEEDS_HUMAN = "goal.needs_human"
+export const GOAL_ROLLED_BACK = "goal.rolled_back"
+
+// §M Expert Panel — the verdict (needs_human → Approval Queue).
+export const PANEL_VERDICT = "panel.verdict"
+
+// §A3 DLQ ALERT — the Event Bus publishes this (source="system", high priority) the moment a delivery
+// exhausts its retries and flips to "dead", so a dead-letter "生成告警" (§A3) instead of sitting silently
+// in the DLQ view until someone queries it. It carries the dead event's id, the subscription group, the
+// final failure reason, and the attempt count so Oversight/notify can surface it. It is a SYSTEM
+// observation signal — NOT an agent-dispatch trigger — so it is guarded against self-cascade at the
+// producer (a dead delivery OF a dlq.alert never re-alerts).
+export const DLQ_ALERT = "dlq.alert"
+
+// §C/§D — a multi-agent subtask that could NOT auto-execute and needs a human: it exceeded the agent's
+// autonomy ceiling, or it is a level_5 suggestion_only action (never auto-runs). The Multi-Agent
+// Runtime publishes this so the §D2 Approval Queue surfaces it for a human decision (rather than the
+// action being silently dropped).
+export const AGENT_TASK_NEEDS_HUMAN = "agent.task.needs_human"
+
+// The set of event types that represent a TERMINAL outcome requiring human attention — the Oversight
+// Approval Queue (§D2) is populated from these. Kept as a set so the wiring can test membership.
+export const APPROVAL_QUEUE_TYPES: ReadonlySet = new Set([
+ GOAL_NEEDS_HUMAN,
+ GOAL_ROLLED_BACK,
+ AGENT_TASK_NEEDS_HUMAN,
+ PANEL_VERDICT, // only when the verdict is needs_human — the wiring checks the payload
+])
+
+// The event types the §L ExecutionArchiver consumes to build Wiki execution-archive pages.
+export const ARCHIVE_TRIGGER_TYPES: ReadonlySet = new Set([SESSION_COMPLETED, GOAL_COMPLETED])
+
+// Is this event type a CANDIDATE for the Approval Queue? Renamed from a definitive-sounding
+// `isApprovalQueueType` because PANEL_VERDICT is only conditionally queued (on decision=needs_human) —
+// a boolean that reads as "yes, queue it" is a footgun. Use `shouldQueueForApproval` for the real
+// yes/no, which folds in the payload check. This candidate check is for coarse routing only.
+export const isApprovalQueueCandidate = (eventType: string): boolean => APPROVAL_QUEUE_TYPES.has(eventType)
+
+// The DEFINITIVE §D2 Approval-Queue test: does this specific event require human approval? Folds the
+// payload gate PANEL_VERDICT needs (only queue a needs_human verdict, not approve/revise/block) so no
+// caller can accidentally flood the queue with autonomously-resolved verdicts.
+export const shouldQueueForApproval = (event: { readonly type: string; readonly payload: unknown }): boolean => {
+ if (event.type === PANEL_VERDICT) {
+ const decision = (event.payload as { decision?: string } | null)?.decision
+ return decision === "needs_human"
+ }
+ return APPROVAL_QUEUE_TYPES.has(event.type)
+}
+
+// §N bridge: the existing producer emits ONE goal event `goal.updated` with a `phase` discriminator
+// (goal-event.ts) rather than the discrete goal.* types below. This maps a driver phase to the discrete
+// V4.0 event type so the event-driven wiring can re-emit / route it onto the bus consistently. Returns
+// undefined for phases that are not a discrete V4.0 lifecycle event (running/paused/stopped are
+// transient status, not queue/archive triggers).
+export const goalPhaseToEventType = (phase: string): string | undefined => {
+ switch (phase) {
+ case "done":
+ return GOAL_COMPLETED
+ case "needs_human":
+ return GOAL_NEEDS_HUMAN
+ case "rolled_back":
+ return GOAL_ROLLED_BACK
+ default:
+ return undefined // running | paused | stopped — no discrete lifecycle event
+ }
+}
+
+// Should an event type trigger Wiki execution archival (§L event-driven archiver)?
+export const isArchiveTrigger = (eventType: string): boolean => ARCHIVE_TRIGGER_TYPES.has(eventType)
diff --git a/packages/core/src/deepagent/observability.ts b/packages/core/src/deepagent/observability.ts
new file mode 100644
index 00000000..881ea635
--- /dev/null
+++ b/packages/core/src/deepagent/observability.ts
@@ -0,0 +1,463 @@
+export * as Observability from "./observability"
+
+import { Context, Effect, Layer } from "effect"
+import { and, asc, eq, gt, gte, inArray, lte, or, sql } from "drizzle-orm"
+import { Database } from "../database/database"
+import { DeepAgentEventTable, DeepAgentEventDeliveryTable, DeepAgentEventDropTable } from "./deepagent-event-sql"
+import { AgentPushLogTable } from "../im/push-log-sql"
+import { HumanTakeoverTable } from "./human-takeover-sql"
+import { RollbackAuditTable } from "./rollback-audit-sql"
+import { SessionTable, MessageTable } from "../session/sql"
+import { DeepAgentEvent } from "./deepagent-event"
+
+// V4.0 §F — Observability. Read-only aggregation over the durable substrate this V4.0 work already
+// writes (deepagent_event / deepagent_event_delivery / im_agent_push_logs). Two capabilities:
+// §F2 Trace — given a correlationID, assemble the causal chain of events (the trace spine the
+// Oversight "Event Trace" view renders: event → route → agent run → coordination → …).
+// §F1 Metrics — compute the §F1 counters over a time window (DLQ total, push-rejected-by-reason,
+// agent-task success rate, conflict rate) for the Agent Dashboard.
+//
+// LAYERING: `core`. Pure reads — no dispatch/session. The HTTP/Oversight layer (deepagent-code) calls
+// this and renders. Latency histograms (event_publish_latency_ms / event_to_agent_start_ms) ARE computed
+// here now that the bus records publish_latency_ms on each row and agent.task.started carries the
+// triggering event's id as causationID — nearest-rank percentiles over the window, workspace-scoped.
+
+// One node in a §F2 trace. `kind` discriminates the two halves of the spine:
+// "event" — a durable DeepAgentEvent on the correlation chain (event → route → coordination), with
+// its causal parent.
+// "session" — a CHILD SESSION an agent ran in that was stamped with this correlationID (the §F2
+// trace BACK-HALF). This is what makes the trace follow correlationID down into the child's
+// activity: the Multi-Agent / event runner stamps `metadata.correlationID` on the session it
+// creates, and `trace` reads it back here so the child (and its message/tool-call activity)
+// appears on the same spine as the triggering event.
+// A "session" node reuses eventID/type/source (eventID=sessionID, type="session.activity", source="system")
+// so an existing event-only projection encodes it unchanged; the session-specific detail rides the optional
+// sessionID/title/messageCount fields.
+export interface TraceNode {
+ readonly kind: "event" | "session"
+ readonly eventID: string
+ readonly type: string
+ readonly source: DeepAgentEvent.EventSource
+ readonly causationID?: string
+ readonly createdAt: number
+ readonly payload?: unknown
+ // §F2 back-half — present only on kind:"session" nodes.
+ readonly sessionID?: string
+ readonly title?: string
+ // count of persisted messages in the child session (a light activity summary; 0 when none / unknown).
+ readonly messageCount?: number
+}
+
+// §F1 metric snapshot over a window.
+export interface Metrics {
+ readonly windowFrom: number
+ readonly windowTo: number
+ // dlq_events_total — deliveries that exhausted retries (status=dead). Alarms in Oversight.
+ readonly dlqEventsTotal: number
+ // §A4 event_dropped_total — events the router SHED (backpressure) in the window, from the durable
+ // deepagent_event_drop log. Decomposable by reason (mirrors agentPushRejectedByReason). Total is the
+ // sum across reasons; the by-reason map keys on the DropReason string (currently "backpressure").
+ readonly eventDroppedTotal: number
+ readonly eventDroppedByReason: Readonly>
+ // agent_push_rejected_total, decomposable by reason (blocked:).
+ readonly agentPushRejectedTotal: number
+ readonly agentPushRejectedByReason: Readonly>
+ // agent_task_success_rate — completed / (completed + GENUINE failures) in the window. GENUINE
+ // failures = agent.task.blocked with reason "runner_failed" ONLY; policy blocks (no_capable_agent,
+ // autonomy, security, suggestion_only, dependency_not_met, conflict_*) are normal outcomes, NOT
+ // failures, and are excluded from the denominator. null ⇒ no task activity (distinct from 1.0).
+ readonly agentTaskSuccessRate: number | null
+ readonly agentTaskCompleted: number
+ readonly agentTaskFailed: number
+ // agent_conflict_rate — share of blocked subtasks whose block reason is a conflict, over all blocks.
+ // null ⇒ no blocks in the window.
+ readonly agentConflictRate: number | null
+ readonly agentTaskBlockedTotal: number
+ // total pushes (delivered + digest + blocked) in the window.
+ readonly agentPushTotal: number
+ // §F1 event_publish_latency_ms — P50/P95 of the per-event persist latency (bus writes publish_latency_ms
+ // on each row). Nearest-rank percentiles over the window, workspace-scoped. null ⇒ no samples.
+ readonly eventPublishLatencyMsP50: number | null
+ readonly eventPublishLatencyMsP95: number | null
+ // §F1 event_to_agent_start_ms — P50/P95 of (agent.task.started.created_at − triggering-event.created_at),
+ // joined by the started event's causationID = the trigger event's id, workspace-scoped. null ⇒ no samples.
+ readonly eventToAgentStartMsP50: number | null
+ readonly eventToAgentStartMsP95: number | null
+ // §F human_takeover_total — the count of human takeovers (a human pausing/reverting an agent or claiming
+ // a branch/session) in the window, workspace-scoped. Backs the §D2 Takeover surface's headline count.
+ readonly humanTakeoverTotal: number
+ // §F rollback_total — the count of human-initiated rollbacks (a human reverting an agent-produced change
+ // via SessionRevert) in the window, workspace-scoped. Backs the §D2 Rollback surface's headline count.
+ readonly rollbackTotal: number
+}
+
+// Nearest-rank percentile (§F1 histograms computed in-code, no SQL percentile fn). `p` in [0,1].
+// Returns null for an empty sample set. Sorts ascending; rank = ceil(p·n), clamped to [1,n].
+const percentile = (samples: ReadonlyArray, p: number): number | null => {
+ if (samples.length === 0) return null
+ const sorted = [...samples].sort((a, b) => a - b)
+ const rank = Math.min(sorted.length, Math.max(1, Math.ceil(p * sorted.length)))
+ return sorted[rank - 1]
+}
+
+export interface Interface {
+ /**
+ * §F2 — the causal event chain for a correlationID within a workspace, oldest-first (created_at asc,
+ * id asc). `workspaceID` is REQUIRED: correlationID is a free-form string a producer sets, so two
+ * tenants can collide on the same value — scoping to the workspace prevents a cross-tenant trace leak.
+ */
+ readonly trace: (input: { workspaceID: string; correlationID: string }) => Effect.Effect>
+ /** §F1 — metric snapshot for one workspace over [from, to] (to defaults to now). */
+ readonly metrics: (input: { workspaceID: string; from: number; to?: number }) => Effect.Effect
+}
+
+export class Service extends Context.Service()("@deepagent-code/Observability") {}
+
+export interface LayerOptions {
+ readonly now?: () => number
+}
+
+export const layerWith = (options?: LayerOptions) =>
+ Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const { db } = yield* Database.Service
+ const now = options?.now ?? Date.now
+
+ const trace: Interface["trace"] = (input) =>
+ Effect.gen(function* () {
+ // ── front-half: the durable event chain (event → route → coordination) for this correlationID.
+ const eventRows = yield* db
+ .select()
+ .from(DeepAgentEventTable)
+ .where(
+ and(
+ eq(DeepAgentEventTable.workspace_id, input.workspaceID),
+ eq(DeepAgentEventTable.correlation_id, input.correlationID),
+ ),
+ )
+ // stable causal order: created_at asc, id asc (ids are ascending-monotonic — matches the bus).
+ .orderBy(asc(DeepAgentEventTable.created_at), asc(DeepAgentEventTable.id))
+ .all()
+ .pipe(Effect.orDie)
+
+ const eventNodes = eventRows.map(
+ (r): TraceNode => ({
+ kind: "event",
+ eventID: r.id as string,
+ type: r.type,
+ source: r.source as DeepAgentEvent.EventSource,
+ ...(r.causation_id != null ? { causationID: r.causation_id } : {}),
+ createdAt: r.created_at,
+ payload: r.payload ?? undefined,
+ }),
+ )
+
+ // ── §F2 BACK-HALF: the CHILD SESSIONS an agent ran in that were stamped with this correlationID.
+ // The event/goal runner writes `metadata.correlationID` onto the child session it creates; here we
+ // read it back so the trace follows correlationID DOWN into the child's activity (its message /
+ // tool-call turns), not just the coordination events. SessionTable.metadata is a JSON column →
+ // json_extract('$.correlationID'). Scope to the same routing key the front-half used: an
+ // event-driven child stores workspace_id ONLY for a genuine "wrk"-id but always stores a
+ // directory, while the trace's workspaceID param is `route.workspaceID ?? route.directory` — so
+ // match EITHER column to cover both the workspace- and directory-routed models without leaking
+ // across the tenant boundary (both columns are the routed identity). FAIL SAFE: any failure here
+ // resolves to NO session nodes so the front-half event chain is still returned (never crash the
+ // trace). A best-effort per-session message count gives a light activity summary.
+ const sessionNodes = yield* Effect.gen(function* () {
+ const sessionRows = yield* db
+ .select({
+ id: SessionTable.id,
+ title: SessionTable.title,
+ createdAt: SessionTable.time_created,
+ })
+ .from(SessionTable)
+ .where(
+ and(
+ or(
+ eq(SessionTable.workspace_id, input.workspaceID as never),
+ // compare `directory` via raw sql: the column's custom type encodes the compared value
+ // through an absolute-path validator that THROWS on a non-path routing key (e.g. a
+ // "wrk_"-id) at query-build time — binding the value as a plain string sidesteps that.
+ sql`${SessionTable.directory} = ${input.workspaceID}`,
+ ),
+ eq(sql`json_extract(${SessionTable.metadata}, '$.correlationID')`, input.correlationID),
+ ),
+ )
+ .orderBy(asc(SessionTable.time_created), asc(SessionTable.id))
+ .all()
+ if (sessionRows.length === 0) return [] as TraceNode[]
+
+ // per-session message count (a light activity summary) via ONE grouped query over the matched
+ // sessions — avoids a correlated subquery and stays cheap.
+ const ids = sessionRows.map((r) => r.id as string)
+ const countRows = yield* db
+ .select({ sessionID: MessageTable.session_id, n: sql`count(*)` })
+ .from(MessageTable)
+ .where(inArray(MessageTable.session_id, ids as never))
+ .groupBy(MessageTable.session_id)
+ .all()
+ const countBySession = new Map(countRows.map((r) => [r.sessionID as string, Number(r.n ?? 0)]))
+
+ return sessionRows.map(
+ (r): TraceNode => ({
+ kind: "session",
+ // reuse eventID/type/source so an event-only projection renders this node unchanged.
+ eventID: r.id as string,
+ type: "session.activity",
+ source: "system",
+ createdAt: r.createdAt,
+ sessionID: r.id as string,
+ title: r.title,
+ messageCount: countBySession.get(r.id as string) ?? 0,
+ }),
+ )
+ }).pipe(
+ // fail-safe: a missing session table / json_extract quirk must not crash the trace — just
+ // return the front-half event chain with no session nodes appended.
+ Effect.catchCause(() => Effect.succeed([] as TraceNode[])),
+ )
+
+ // merge both halves into one causally-ordered spine (created_at asc; session nodes interleave at
+ // their creation time — a child session created after its trigger event sorts after it).
+ return [...eventNodes, ...sessionNodes].sort((a, b) => a.createdAt - b.createdAt)
+ })
+
+ const metrics: Interface["metrics"] = (input) =>
+ Effect.gen(function* () {
+ const from = input.from
+ const to = input.to ?? now()
+ const ws = input.workspaceID
+
+ // dlq_events_total — DISTINCT events that dead-lettered in the window, scoped to the workspace
+ // via a join to the event log. count(distinct event_id) so an event dead across N groups
+ // counts once (the metric is "events", not delivery rows).
+ const dlqRow = yield* db
+ .select({ n: sql`count(distinct ${DeepAgentEventDeliveryTable.event_id})` })
+ .from(DeepAgentEventDeliveryTable)
+ .innerJoin(DeepAgentEventTable, eq(DeepAgentEventTable.id, DeepAgentEventDeliveryTable.event_id))
+ .where(
+ and(
+ eq(DeepAgentEventTable.workspace_id, ws),
+ eq(DeepAgentEventDeliveryTable.status, "dead"),
+ gte(DeepAgentEventDeliveryTable.updated_at, from),
+ lte(DeepAgentEventDeliveryTable.updated_at, to),
+ ),
+ )
+ .get()
+ .pipe(Effect.orDie)
+
+ // §A4 event_dropped_total — router-shed events (backpressure) in the window, grouped by reason,
+ // from the durable deepagent_event_drop log. Workspace-scoped, windowed on the drop's created_at.
+ // Mirrors the dlq_events_total pattern (a persisted signal, not a log line).
+ const dropRows = yield* db
+ .select({ reason: DeepAgentEventDropTable.reason, n: sql`count(*)` })
+ .from(DeepAgentEventDropTable)
+ .where(
+ and(
+ eq(DeepAgentEventDropTable.workspace_id, ws),
+ gte(DeepAgentEventDropTable.created_at, from),
+ lte(DeepAgentEventDropTable.created_at, to),
+ ),
+ )
+ .groupBy(DeepAgentEventDropTable.reason)
+ .all()
+ .pipe(Effect.orDie)
+ let eventDroppedTotal = 0
+ const eventDroppedByReason: Record = {}
+ for (const row of dropRows) {
+ eventDroppedTotal += row.n
+ eventDroppedByReason[row.reason] = (eventDroppedByReason[row.reason] ?? 0) + row.n
+ }
+
+ // agent_push_* — from im_agent_push_logs in the window, scoped to the workspace.
+ const pushRows = yield* db
+ .select({ decision: AgentPushLogTable.decision, n: sql`count(*)` })
+ .from(AgentPushLogTable)
+ .where(
+ and(
+ eq(AgentPushLogTable.workspace_id, ws),
+ gte(AgentPushLogTable.created_at, from),
+ lte(AgentPushLogTable.created_at, to),
+ ),
+ )
+ .groupBy(AgentPushLogTable.decision)
+ .all()
+ .pipe(Effect.orDie)
+
+ let agentPushTotal = 0
+ let agentPushRejectedTotal = 0
+ const agentPushRejectedByReason: Record = {}
+ for (const row of pushRows) {
+ agentPushTotal += row.n
+ if (row.decision.startsWith("blocked:")) {
+ agentPushRejectedTotal += row.n
+ const reason = row.decision.slice("blocked:".length)
+ agentPushRejectedByReason[reason] = (agentPushRejectedByReason[reason] ?? 0) + row.n
+ }
+ }
+
+ // agent task outcomes — read the coordination events (workspace-scoped) and classify by the
+ // block REASON in the payload (not just the type). completed = success; blocked splits into
+ // GENUINE failure (runner_failed) vs normal policy block (everything else); conflict blocks
+ // feed the conflict rate.
+ const outcomeRows = yield* db
+ .select({ type: DeepAgentEventTable.type, payload: DeepAgentEventTable.payload })
+ .from(DeepAgentEventTable)
+ .where(
+ and(
+ eq(DeepAgentEventTable.workspace_id, ws),
+ gte(DeepAgentEventTable.created_at, from),
+ lte(DeepAgentEventTable.created_at, to),
+ sql`${DeepAgentEventTable.type} in ('agent.task.completed', 'agent.task.blocked')`,
+ ),
+ )
+ .all()
+ .pipe(Effect.orDie)
+
+ let agentTaskCompleted = 0
+ let agentTaskFailed = 0 // genuine failures (runner_failed) only
+ let agentTaskBlockedTotal = 0
+ let conflictBlocks = 0
+ for (const row of outcomeRows) {
+ if (row.type === "agent.task.completed") {
+ agentTaskCompleted++
+ continue
+ }
+ agentTaskBlockedTotal++
+ const reason = (row.payload as { reason?: string } | null)?.reason ?? ""
+ if (reason === "runner_failed") agentTaskFailed++
+ if (reason.startsWith("conflict")) conflictBlocks++
+ }
+ const denom = agentTaskCompleted + agentTaskFailed
+ const agentTaskSuccessRate = denom === 0 ? null : agentTaskCompleted / denom
+ const agentConflictRate = agentTaskBlockedTotal === 0 ? null : conflictBlocks / agentTaskBlockedTotal
+
+ // §F1 event_publish_latency_ms — the bus writes publish_latency_ms on each event row; read the
+ // non-null samples in the window (workspace-scoped) and compute nearest-rank P50/P95 in-code.
+ const latencyRows = yield* db
+ .select({ ms: DeepAgentEventTable.publish_latency_ms })
+ .from(DeepAgentEventTable)
+ .where(
+ and(
+ eq(DeepAgentEventTable.workspace_id, ws),
+ gte(DeepAgentEventTable.created_at, from),
+ lte(DeepAgentEventTable.created_at, to),
+ sql`${DeepAgentEventTable.publish_latency_ms} is not null`,
+ ),
+ )
+ .all()
+ .pipe(Effect.orDie)
+ const latencySamples = latencyRows.map((r) => r.ms as number)
+ const eventPublishLatencyMsP50 = percentile(latencySamples, 0.5)
+ const eventPublishLatencyMsP95 = percentile(latencySamples, 0.95)
+
+ // §F1 event_to_agent_start_ms — for each agent.task.started event in the window, the delay from
+ // its TRIGGER (the event whose id == the started event's causationID) to the start. We read the
+ // started rows, then resolve each causationID to its trigger's created_at via a workspace-scoped
+ // id→created_at map; sample = started.created_at − trigger.created_at. Joining in-code (rather
+ // than a correlated self-join on the same physical table) keeps the query unambiguous and
+ // mirrors the in-code percentile idiom.
+ const startedRows = yield* db
+ .select({ createdAt: DeepAgentEventTable.created_at, causationID: DeepAgentEventTable.causation_id })
+ .from(DeepAgentEventTable)
+ .where(
+ and(
+ eq(DeepAgentEventTable.workspace_id, ws),
+ eq(DeepAgentEventTable.type, "agent.task.started"),
+ gte(DeepAgentEventTable.created_at, from),
+ lte(DeepAgentEventTable.created_at, to),
+ sql`${DeepAgentEventTable.causation_id} is not null`,
+ ),
+ )
+ .all()
+ .pipe(Effect.orDie)
+
+ const startSamples: number[] = []
+ const triggerIDs = [...new Set(startedRows.map((r) => r.causationID).filter((c): c is string => c != null))]
+ if (triggerIDs.length > 0) {
+ // resolve trigger created_at, scoped to THIS workspace so a cross-tenant id collision can't
+ // pair a started event to another tenant's trigger.
+ const triggerRows = yield* db
+ .select({ id: DeepAgentEventTable.id, createdAt: DeepAgentEventTable.created_at })
+ .from(DeepAgentEventTable)
+ .where(
+ and(
+ eq(DeepAgentEventTable.workspace_id, ws),
+ inArray(DeepAgentEventTable.id, triggerIDs as DeepAgentEvent.ID[]),
+ ),
+ )
+ .all()
+ .pipe(Effect.orDie)
+ const triggerAt = new Map(triggerRows.map((r) => [r.id as string, r.createdAt]))
+ for (const r of startedRows) {
+ const t = r.causationID != null ? triggerAt.get(r.causationID) : undefined
+ // only pair to a trigger that exists in this workspace, and never emit a negative sample.
+ if (t != null && r.createdAt >= t) startSamples.push(r.createdAt - t)
+ }
+ }
+ const eventToAgentStartMsP50 = percentile(startSamples, 0.5)
+ const eventToAgentStartMsP95 = percentile(startSamples, 0.95)
+
+ // §F human_takeover_total — count the human-takeover audit rows in the window (workspace-scoped).
+ // The takeover log is its own table (deepagent_human_takeover) written by the §D2 Takeover
+ // endpoint; a fresh workspace with no takeovers reads 0 (never null — a takeover is a plain count).
+ const takeoverRow = yield* db
+ .select({ n: sql`count(*)` })
+ .from(HumanTakeoverTable)
+ .where(
+ and(
+ eq(HumanTakeoverTable.workspace_id, ws),
+ gte(HumanTakeoverTable.created_at, from),
+ lte(HumanTakeoverTable.created_at, to),
+ ),
+ )
+ .get()
+ .pipe(Effect.orDie)
+
+ // §F rollback_total — count the rollback audit rows in the window (workspace-scoped). The
+ // rollback log is its own table (deepagent_rollback) written by the §D2 Rollback endpoint after
+ // invoking SessionRevert; a fresh workspace with no rollbacks reads 0 (never null — a plain count).
+ const rollbackRow = yield* db
+ .select({ n: sql`count(*)` })
+ .from(RollbackAuditTable)
+ .where(
+ and(
+ eq(RollbackAuditTable.workspace_id, ws),
+ gte(RollbackAuditTable.created_at, from),
+ lte(RollbackAuditTable.created_at, to),
+ ),
+ )
+ .get()
+ .pipe(Effect.orDie)
+
+ return {
+ windowFrom: from,
+ windowTo: to,
+ dlqEventsTotal: dlqRow?.n ?? 0,
+ eventDroppedTotal,
+ eventDroppedByReason,
+ agentPushRejectedTotal,
+ agentPushRejectedByReason,
+ agentTaskSuccessRate,
+ agentTaskCompleted,
+ agentTaskFailed,
+ agentConflictRate,
+ agentTaskBlockedTotal,
+ agentPushTotal,
+ eventPublishLatencyMsP50,
+ eventPublishLatencyMsP95,
+ eventToAgentStartMsP50,
+ eventToAgentStartMsP95,
+ humanTakeoverTotal: takeoverRow?.n ?? 0,
+ rollbackTotal: rollbackRow?.n ?? 0,
+ }
+ })
+
+ return Service.of({ trace, metrics })
+ }),
+ )
+
+export const layer = layerWith()
+
+export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
diff --git a/packages/core/src/deepagent/panel-convene-policy.ts b/packages/core/src/deepagent/panel-convene-policy.ts
new file mode 100644
index 00000000..0895c77c
--- /dev/null
+++ b/packages/core/src/deepagent/panel-convene-policy.ts
@@ -0,0 +1,80 @@
+export * as PanelConvenePolicy from "./panel-convene-policy"
+
+import { DeepAgentEvent } from "./deepagent-event"
+
+// V4.0 §M — the Expert Panel AUTO-CONVENE policy. In V3.9 a panel was convened only by an explicit
+// in-session Convener call; V4.0 lets the Event Router auto-summon a panel for high-risk events
+// (destructive migration PRs, security alerts, architecture changes) AFTER a policy check. This module
+// is that PURE policy: given an event, decide whether to auto-convene, and if so at what urgency. The
+// deepagent-code wiring subscribes to the bus, calls shouldConvene(), and (on convene) drives the
+// EXISTING V3.9 panel orchestrator — this module adds NO new panel mechanics (Arbiter, same-question
+// dispatch, minority retention, fail-closed all stay V3.9).
+//
+// LAYERING: `core`, pure (no Effect/DB). The wiring resolves the flag + rate limits and dispatches.
+
+// A signal that marks an event as high-risk enough to warrant a panel. Rule-driven so new risk classes
+// are declarative. `match` is the event type (exact or `prefix.*`); `when` is an optional predicate on
+// the payload for finer control (e.g. only destructive migrations, only high/critical alerts).
+export interface RiskRule {
+ readonly match: string
+ readonly riskClass: RiskClass
+ // optional payload predicate; omitted ⇒ the type match alone qualifies.
+ readonly when?: (payload: Record) => boolean
+}
+
+export type RiskClass = "security" | "destructive_migration" | "architecture_change" | "repeated_failure"
+
+const matchesType = (pattern: string, eventType: string): boolean => {
+ if (pattern === eventType || pattern === "*") return true
+ if (pattern.endsWith(".*")) return eventType.startsWith(pattern.slice(0, -1))
+ return false
+}
+
+const asRecord = (payload: unknown): Record =>
+ payload && typeof payload === "object" ? (payload as Record) : {}
+
+// §M default high-risk rules. Ordered; the FIRST matching rule classifies the event.
+export const DEFAULT_RULES: ReadonlyArray = [
+ // security alerts always convene.
+ { match: "monitor.alert", riskClass: "security", when: (p) => p.category === "security" || p.severity === "critical" },
+ // a PR flagged as a destructive/irreversible migration.
+ { match: "pr.comment", riskClass: "destructive_migration", when: (p) => p.destructive === true || p.migration === true },
+ { match: "git.push", riskClass: "destructive_migration", when: (p) => p.destructive === true },
+ // an explicit architecture-change signal.
+ { match: "pr.comment", riskClass: "architecture_change", when: (p) => p.architectureChange === true },
+ // CI failing repeatedly (the §A4 condition-trigger shape) is worth a diagnostic panel.
+ { match: "ci.failure", riskClass: "repeated_failure", when: (p) => typeof p.consecutiveFailures === "number" && (p.consecutiveFailures as number) >= 3 },
+]
+
+/**
+ * §M — decide whether an event auto-convenes a panel.
+ * 1. flag gate → skip flag_disabled when the auto-convene feature is off (fail-closed: no panel).
+ * 2. risk match → skip no_risk_match when no risk rule applies.
+ * 3. convene → carry the risk class + an urgency derived from the event priority (critical/high
+ * events keep their urgency; a matched security risk is escalated to at least high).
+ * Enabling the feature is intentionally distinct from the panel body being available — a disabled flag
+ * means "don't auto-summon", NOT "panels don't exist" (explicit V3.9 convening is unaffected).
+ */
+export const shouldConvene = (input: {
+ readonly event: DeepAgentEvent.Event
+ readonly flagEnabled: boolean
+ readonly rules?: ReadonlyArray
+}):
+ | { readonly type: "convene"; readonly riskClass: RiskClass; readonly urgency: DeepAgentEvent.EventPriority }
+ | { readonly type: "skip"; readonly reason: "flag_disabled" | "no_risk_match" } => {
+ if (!input.flagEnabled) return { type: "skip", reason: "flag_disabled" }
+
+ const rules = input.rules ?? DEFAULT_RULES
+ const payload = asRecord(input.event.payload)
+ const rule = rules.find((r) => matchesType(r.match, input.event.type) && (r.when ? r.when(payload) : true))
+ if (!rule) return { type: "skip", reason: "no_risk_match" }
+
+ // urgency: an auto-convened panel is BY DEFINITION high-risk, so floor EVERY convening event to at
+ // least "high". This matters because §A4 backpressure (event-router.ts) drops low/normal events when
+ // the queue is full — a destructive-migration/architecture/security convene request must not be
+ // silently discardable at low/normal. Critical is carried through unchanged.
+ const base = input.event.priority
+ const urgency: DeepAgentEvent.EventPriority = base === "critical" ? "critical" : "high"
+
+ return { type: "convene", riskClass: rule.riskClass, urgency }
+}
diff --git a/packages/core/src/deepagent/path-acl.ts b/packages/core/src/deepagent/path-acl.ts
new file mode 100644
index 00000000..b9bf44c7
--- /dev/null
+++ b/packages/core/src/deepagent/path-acl.ts
@@ -0,0 +1,45 @@
+export * as PathAcl from "./path-acl"
+
+import { isAbsolute, relative, resolve as pathResolve, sep } from "path"
+
+// V4.0 §E3 — the FILE-PATH ACL. A PURE, deterministic containment check: given a set of allowed
+// workspace roots and a candidate path, decide whether the candidate resolves to somewhere INSIDE one
+// of those roots. This is the "文件路径权限" leg of §E3 that ContentSafety.scrub deliberately punts to the
+// caller (content-safety.ts line ~13) — it lives here so both the scrubber and the agent-push path
+// (packages/deepagent-code/src/session/agent-push.ts ~line 82) can share ONE fail-closed policy.
+//
+// LAYERING: lives in `core` and imports only node `path` — no FS IO, no config store. Containment is
+// decided LEXICALLY after `path.resolve` collapses `.`/`..` segments, so traversal (`../../etc/passwd`),
+// absolute escapes (`/etc/passwd`), and home-dir escapes (`~` expanded by the caller, or an absolute
+// `/Users/...` outside a root) are all rejected without touching the filesystem. True symlink
+// resolution (realpath) is an IO concern the caller layers on when it matters; this stays pure/testable.
+
+// Collapse `.`/`..` and compare: is `child` the same as, or nested under, `parent`? Mirrors
+// FSUtil.contains but is inlined here to keep this module's dependency surface to node `path` only
+// (FSUtil pulls in the platform FS layer, which a pure ACL check must not drag in).
+const contains = (parent: string, child: string): boolean => {
+ const rel = relative(parent, child)
+ return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`))
+}
+
+/**
+ * §E3 — is `candidate` allowed, i.e. does it resolve to WITHIN one of `allowedRoots`?
+ *
+ * Fail-closed: an EMPTY `allowedRoots` allows nothing. An ABSOLUTE candidate is resolved on its own and
+ * must land inside some root. A RELATIVE candidate is resolved against EACH root in turn (so
+ * workspace-relative paths like `src/app.ts` are allowed, while `../../etc/passwd` collapses to outside
+ * every root and is rejected). Roots are `path.resolve`d first so relative roots are handled too.
+ */
+export const isPathAllowed = (candidate: string, allowedRoots: ReadonlyArray): boolean => {
+ if (allowedRoots.length === 0) return false
+ if (candidate.length === 0) return false
+ const abs = isAbsolute(candidate)
+ for (const root of allowedRoots) {
+ const normRoot = pathResolve(root)
+ // absolute candidate: resolve standalone. relative candidate: resolve UNDER this root (so `..`
+ // that climbs above the root collapses to a path `contains` then rejects).
+ const resolved = abs ? pathResolve(candidate) : pathResolve(normRoot, candidate)
+ if (contains(normRoot, resolved)) return true
+ }
+ return false
+}
diff --git a/packages/core/src/deepagent/prompt-policy.ts b/packages/core/src/deepagent/prompt-policy.ts
index f67a7b80..bd126020 100644
--- a/packages/core/src/deepagent/prompt-policy.ts
+++ b/packages/core/src/deepagent/prompt-policy.ts
@@ -142,9 +142,12 @@ export const buildSystemPrompt = (ctx: PromptContext): string => {
sections.push(ctx.bridge)
}
- if (ctx.knowledge && knowledgeEnabled(ctx.mode)) {
- sections.push(knowledgeSection(ctx.knowledge))
- }
+ // BUG #5 (prompt-cache): knowledge is populated LAZILY — round 1 on a fresh/empty store returns
+ // null (no section), and a later retrieval-enabled round returns non-null, which USED to insert the
+ // section into the cached prefix mid-session and bust the cache for the rest of the session. Mirror
+ // the T4.4 fan-out guard: knowledge is per-session-volatile w.r.t. WHEN it first appears, so it does
+ // NOT belong in the byte-stable prefix. It is rendered in buildVolatileRoundContext instead (tail,
+ // after the cache breakpoint), so the model still sees it without ever churning the prefix.
sections.push(constraintsSection(ctx.mode))
@@ -168,6 +171,13 @@ export const buildVolatileRoundContext = (ctx: PromptContext): string => {
const roundLine = `第 ${ctx.round} 轮 · 阶段 ${ctx.activation.stage}`
sections.push(["# 本轮状态 (round context)", "", roundLine].join("\n"))
+ // MEDIUM cache-buster fix: the current date was moved OUT of environmentSection (cached prefix) —
+ // it advances at midnight and would bust the whole prefix once per day. Render it here in the tail
+ // so the model still knows "today" without churning the cache.
+ if (ctx.environment.date) {
+ sections.push(["# 日期 (date)", "", `- Date: ${ctx.environment.date}`].join("\n"))
+ }
+
// Activation guidance: the stage-specific how-to-work prose. Stage advances across rounds, so this
// is round-derived and must not sit in the cached prefix.
if (ctx.activation.guidance.trim()) {
@@ -184,6 +194,15 @@ export const buildVolatileRoundContext = (ctx: PromptContext): string => {
const fanout = ctx.fanoutDecision ? fanoutVerdictLines(ctx.fanoutDecision) : null
if (fanout) sections.push(fanout)
+ // BUG #5 (prompt-cache): knowledge is retrieved LAZILY (null on a fresh store round 1, non-null on a
+ // later retrieval-enabled round). It used to live in the cached system prefix, so its late appearance
+ // busted the prefix mid-session (~10× cost). It is advisory, round-derived context — render it here in
+ // the volatile tail (after the cache breakpoint) so the model still sees it without ever churning the
+ // prefix. The `knowledgeEnabled(mode)` gate is preserved from the original prefix placement.
+ if (ctx.knowledge && knowledgeEnabled(ctx.mode)) {
+ sections.push(knowledgeSection(ctx.knowledge))
+ }
+
if (ctx.previousResults && ctx.round > 1) {
sections.push(previousResultsSection(ctx.previousResults))
}
@@ -229,6 +248,10 @@ const identitySection = (mode: AgentMode): string => {
}
const environmentSection = (env: EnvironmentContext): string => {
+ // NOTE (prompt-cache): `- Date` is intentionally NOT rendered here. The date advances at midnight,
+ // so baking it into the cached prefix busts the whole prefix once per day (MEDIUM cache-buster). It
+ // is rendered in buildVolatileRoundContext (tail, after the cache breakpoint) instead — same policy
+ // as every other volatile env-derived value. Everything left here is session-stable.
const lines = [
"# Environment",
"",
@@ -236,7 +259,6 @@ const environmentSection = (env: EnvironmentContext): string => {
`- Shell: ${env.shell}`,
`- CWD: ${env.cwd}`,
`- Platform: ${env.platform}`,
- `- Date: ${env.date}`,
]
if (env.isGitRepo) {
lines.push(`- Git: ${env.gitBranch ?? "unknown branch"}${env.gitRoot ? ` (root: ${env.gitRoot})` : ""}`)
diff --git a/packages/core/src/deepagent/quiet-hours.ts b/packages/core/src/deepagent/quiet-hours.ts
new file mode 100644
index 00000000..5c0def54
--- /dev/null
+++ b/packages/core/src/deepagent/quiet-hours.ts
@@ -0,0 +1,75 @@
+export * as QuietHours from "./quiet-hours"
+
+import { DeepAgentEvent } from "./deepagent-event"
+
+// V4.0 §E4 — the QUIET-HOURS decision POLICY. A PURE, deterministic function: given the event's
+// priority and whether "now" falls inside the workspace's configured quiet window, it decides whether
+// a proactive push is delivered immediately, deferred into a digest, or delivered instantly-but-logged.
+//
+// LAYERING: lives in `core`, imports NOTHING runtime. The caller resolves `withinQuietHours` (via the
+// `isWithinQuietHours` helper below or its own tz logic) and passes it in, so this stays pure.
+//
+// §E4 责任, mapped to `decide`:
+// normal/low proactive push during quiet hours → 汇总为摘要 (digest), delivered when quiet hours end.
+// high/critical → 允许即时送达, but the caller MUST record the reason (requiresReason:true).
+// outside quiet hours → deliver normally.
+
+export type QuietHoursDecision =
+ | { readonly action: "deliver" }
+ | { readonly action: "digest" }
+ | { readonly action: "deliver"; readonly requiresReason: true }
+
+export interface QuietHoursInput {
+ readonly priority: DeepAgentEvent.EventPriority
+ // resolved by the caller (see `isWithinQuietHours`) — is "now" inside the workspace's quiet window?
+ readonly withinQuietHours: boolean
+}
+
+/**
+ * §E4 — the pure quiet-hours decision:
+ * - outside quiet hours → { action: "deliver" }.
+ * - inside, low/normal priority → { action: "digest" } (defer into the quiet-hours digest).
+ * - inside, high/critical priority → { action: "deliver", requiresReason: true } (instant, but the
+ * caller MUST record WHY it broke through quiet hours).
+ */
+export const decide = (input: QuietHoursInput): QuietHoursDecision => {
+ if (!input.withinQuietHours) return { action: "deliver" }
+
+ if (input.priority === "high" || input.priority === "critical") {
+ return { action: "deliver", requiresReason: true }
+ }
+
+ return { action: "digest" }
+}
+
+/**
+ * §E4 helper — does the instant `now` (epoch ms, UTC) fall within [startHour, endHour) in the
+ * workspace's local time? Pure and deterministic.
+ *
+ * `tzOffsetMinutes` is the workspace's offset from UTC in minutes (e.g. +480 for UTC+8, -300 for
+ * UTC-5); it defaults to 0 (UTC). The local hour is derived arithmetically from the epoch so there is
+ * no dependency on the host's timezone or Date locale.
+ *
+ * Wrap-around: when `startHour > endHour` the window spans midnight (e.g. 22→6 means 22:00–05:59), so
+ * an hour qualifies if it is >= start OR < end. When `startHour === endHour` the window is empty
+ * (never quiet). The comparison is inclusive of `startHour` and exclusive of `endHour`.
+ */
+export const isWithinQuietHours = (
+ now: number,
+ startHour: number,
+ endHour: number,
+ tzOffsetMinutes: number = 0,
+): boolean => {
+ if (startHour === endHour) return false
+
+ // Shift epoch by the tz offset, then take the hour-of-day in [0,24).
+ const localMs = now + tzOffsetMinutes * 60_000
+ const localHour = Math.floor(localMs / 3_600_000) % 24
+ const hour = localHour < 0 ? localHour + 24 : localHour
+
+ if (startHour < endHour) {
+ return hour >= startHour && hour < endHour
+ }
+ // wrap-around window spanning midnight.
+ return hour >= startHour || hour < endHour
+}
diff --git a/packages/core/src/deepagent/rate-limiter.ts b/packages/core/src/deepagent/rate-limiter.ts
new file mode 100644
index 00000000..6f27fda3
--- /dev/null
+++ b/packages/core/src/deepagent/rate-limiter.ts
@@ -0,0 +1,81 @@
+export * as RateLimiter from "./rate-limiter"
+
+// V4.0 §E2 — the in-memory fixed-window RATE LIMITER. Mirrors the existing `class RateLimiter` in
+// deepagent-code (server/routes/instance/httpapi/handlers/im.ts): a per-key bucket that resets after a
+// fixed window. Unlike the pure policy modules this one carries a tiny amount of state (the buckets),
+// so it is a plain class — NOT Effect. It is still fully deterministic: pass an injectable `now` so
+// tests can cross a window boundary without a real clock.
+//
+// LAYERING: lives in `core`, imports NOTHING runtime. The wiring owns a single instance and calls
+// `check` on the hot path; a periodic `sweep` drops expired buckets to bound memory.
+//
+// §E2 defaults are LENIENT and configurable — they are exported as the constants below so callers pass
+// them in explicitly. Nothing restrictive is baked into `check`; the limit/window are always parameters.
+
+interface Bucket {
+ count: number
+ resetAt: number
+}
+
+// Named `Service` (not `RateLimiter`) to mirror the core self-barreled-class idiom (Scheduler.Service,
+// GraphQuery.Service, DeepAgentEventBus.Service) — a class named `RateLimiter` would collide with the
+// `export * as RateLimiter` barrel. Callers use `RateLimiter.Service`.
+export class Service {
+ private buckets = new Map()
+
+ /**
+ * §E2 — is another hit under `key` allowed within the current window? Returns true and records the
+ * hit when under `limit`; returns false (over limit) otherwise. A new or expired bucket resets to a
+ * fresh window starting at `now`. `now` is injectable for deterministic tests (defaults to Date.now).
+ */
+ check(key: string, limit: number, windowMs: number, now: number = Date.now()): boolean {
+ const bucket = this.buckets.get(key)
+
+ if (!bucket || now >= bucket.resetAt) {
+ this.buckets.set(key, { count: 1, resetAt: now + windowMs })
+ return true
+ }
+
+ if (bucket.count >= limit) {
+ return false
+ }
+
+ bucket.count++
+ return true
+ }
+
+ /**
+ * Drop buckets whose window has elapsed as of `now`, bounding memory for idle keys. Returns the
+ * number of buckets pruned (0 when nothing was stale). A still-live bucket (window not yet elapsed)
+ * is preserved untouched, so this is a selective prune, never a blanket reset. The count is a cheap
+ * observability signal for the periodic sweep daemon and makes the prune deterministically testable.
+ */
+ sweep(now: number = Date.now()): number {
+ let pruned = 0
+ for (const [key, bucket] of this.buckets.entries()) {
+ if (now >= bucket.resetAt) {
+ this.buckets.delete(key)
+ pruned++
+ }
+ }
+ return pruned
+ }
+
+ /** Number of live buckets currently held — a memory-footprint probe for the sweep daemon + tests. */
+ size(): number {
+ return this.buckets.size
+ }
+}
+
+// §E2 defaults — LENIENT ceilings, meant to be overridden per workspace/agent config. Documented as
+// defaults, not enforced minimums; the limiter takes limit/window as parameters on every call.
+
+// Event publish: 1000 events / minute, keyed per workspace.
+export const EVENT_PUBLISH_PER_WORKSPACE = { limit: 1000, windowMs: 60_000 } as const
+
+// Agent proactive push: 20 pushes / hour, keyed per agent per group.
+export const AGENT_PUSH_PER_AGENT_GROUP = { limit: 20, windowMs: 3_600_000 } as const
+
+// Agent execution: at most 5 concurrent runs per workspace (a concurrency cap, not a window rate —
+// enforced by the caller's in-flight counter, surfaced here as the default ceiling).
+export const AGENT_EXEC_CONCURRENT_PER_WORKSPACE = 5 as const
diff --git a/packages/core/src/deepagent/retention-sweeper.ts b/packages/core/src/deepagent/retention-sweeper.ts
new file mode 100644
index 00000000..6686d9bc
--- /dev/null
+++ b/packages/core/src/deepagent/retention-sweeper.ts
@@ -0,0 +1,156 @@
+export * as RetentionSweeper from "./retention-sweeper"
+
+import { Cause, Context, Duration, Effect, Layer, Schedule } from "effect"
+import { and, eq, lt } from "drizzle-orm"
+import { Database } from "../database/database"
+import { DeepAgentEventBus } from "./deepagent-event-bus"
+import { DeepAgentEventTable } from "./deepagent-event-sql"
+import { ApprovalQueueTable } from "./approval-queue-sql"
+import { WorkspaceConfig } from "./workspace-config"
+import { AgentPushLogTable } from "../im/push-log-sql"
+import * as Log from "../util/log"
+
+// V4.0 §A3 保留期 — the periodic RETENTION SWEEPER. For each workspace that has durable events it reads
+// the workspace's configured `retentionDays` (WorkspaceConfig, default 30) and prunes anything older
+// than `now - retentionDays*86400_000`:
+// - domain events → DeepAgentEventBus.sweep (referential-safe: spares events still owed to
+// a pending delivery or an unresolved approval-queue item; see the bus).
+// - agent push audit log → im_agent_push_logs rows past retention (the §B4 push audit trail).
+// - resolved approval queue → deepagent_approval_queue rows already RESOLVED and past retention. A
+// PENDING item is NEVER pruned (a human still owes it a decision), no
+// matter how old — audit retention only reclaims settled state.
+//
+// LAYERING: `core`. Reads WorkspaceConfig + drives the Event Bus; no session/runtime imports. The daemon
+// is a scoped fork gated behind `runLoop` (tests pass false and call `sweepOnce` for determinism).
+
+const log = Log.create({ service: "retention-sweeper" })
+
+const DAY_MS = 86_400_000
+// default sweep cadence — hourly (retention is a slow reclaim; a missed hour is harmless).
+export const DEFAULT_SWEEP_INTERVAL_MS = Duration.toMillis(Duration.hours(1))
+
+export interface SweepSummary {
+ readonly workspacesSwept: number
+ readonly deletedEvents: number
+ readonly deletedPushLogs: number
+ readonly deletedApprovals: number
+}
+
+export interface Interface {
+ /**
+ * Run ONE retention pass across every workspace that has events. Deterministic (no timers) so tests
+ * can drive it directly; the daemon calls it on the interval. `now` defaults to the injected clock.
+ */
+ readonly sweepOnce: (now?: number) => Effect.Effect
+}
+
+export class Service extends Context.Service()("@deepagent-code/RetentionSweeper") {}
+
+export interface LayerOptions {
+ readonly now?: () => number
+ // sweep cadence for the daemon loop. Ignored when runLoop is false.
+ readonly intervalMs?: number
+ // start the background sweep daemon (scoped fork). Default true; tests pass false and call sweepOnce.
+ readonly runLoop?: boolean
+}
+
+export const layerWith = (options?: LayerOptions) =>
+ Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const { db } = yield* Database.Service
+ const bus = yield* DeepAgentEventBus.Service
+ const config = yield* WorkspaceConfig.Service
+ const now = options?.now ?? Date.now
+ const intervalMs = options?.intervalMs ?? DEFAULT_SWEEP_INTERVAL_MS
+ const runLoop = options?.runLoop ?? true
+
+ const sweepOnce: Interface["sweepOnce"] = (nowArg) =>
+ Effect.gen(function* () {
+ const at = nowArg ?? now()
+
+ // enumerate the workspaces that actually have events — the only ones worth a retention pass.
+ // (push-log / approval pruning is scoped to these same workspaces; a workspace with no events
+ // but stray audit rows is swept the next time it publishes — acceptable for slow reclaim.)
+ const workspaceRows = yield* db
+ .selectDistinct({ workspaceID: DeepAgentEventTable.workspace_id })
+ .from(DeepAgentEventTable)
+ .all()
+ .pipe(Effect.orDie)
+
+ let deletedEvents = 0
+ let deletedPushLogs = 0
+ let deletedApprovals = 0
+
+ for (const { workspaceID } of workspaceRows) {
+ const resolved = yield* config.get(workspaceID)
+ const olderThan = at - resolved.retentionDays * DAY_MS
+
+ // §A3 events (referential-safe sweep on the bus).
+ const eventResult = yield* bus.sweep({ workspaceID, olderThan })
+ deletedEvents += eventResult.deletedEvents
+
+ // §B4 push audit log — prune this workspace's rows past retention.
+ const pushDeleted = yield* db
+ .delete(AgentPushLogTable)
+ .where(
+ and(
+ eq(AgentPushLogTable.workspace_id, workspaceID),
+ lt(AgentPushLogTable.created_at, olderThan),
+ ),
+ )
+ .returning({ id: AgentPushLogTable.id })
+ .all()
+ .pipe(Effect.orDie)
+ deletedPushLogs += pushDeleted.length
+
+ // §D2 approval queue — prune RESOLVED items past retention only. A pending item survives
+ // regardless of age (a human still owes it a decision).
+ const approvalDeleted = yield* db
+ .delete(ApprovalQueueTable)
+ .where(
+ and(
+ eq(ApprovalQueueTable.workspace_id, workspaceID),
+ eq(ApprovalQueueTable.status, "resolved"),
+ lt(ApprovalQueueTable.created_at, olderThan),
+ ),
+ )
+ .returning({ id: ApprovalQueueTable.id })
+ .all()
+ .pipe(Effect.orDie)
+ deletedApprovals += approvalDeleted.length
+ }
+
+ return {
+ workspacesSwept: workspaceRows.length,
+ deletedEvents,
+ deletedPushLogs,
+ deletedApprovals,
+ }
+ })
+
+ // Background daemon (scoped to the layer). A failure in a single pass is logged and swallowed so
+ // the loop never dies on one bad sweep. Schedule.spaced waits between completions.
+ if (runLoop) {
+ yield* sweepOnce()
+ .pipe(
+ Effect.catchCause((cause) =>
+ Effect.sync(() => log.error("retention sweep failed", { cause: Cause.pretty(cause) })).pipe(
+ Effect.as({
+ workspacesSwept: 0,
+ deletedEvents: 0,
+ deletedPushLogs: 0,
+ deletedApprovals: 0,
+ }),
+ ),
+ ),
+ Effect.repeat(Schedule.spaced(Duration.millis(intervalMs))),
+ Effect.forkScoped,
+ )
+ }
+
+ return Service.of({ sweepOnce })
+ }),
+ )
+
+export const layer = layerWith()
diff --git a/packages/core/src/deepagent/rollback-audit-sql.ts b/packages/core/src/deepagent/rollback-audit-sql.ts
new file mode 100644
index 00000000..e473dcc4
--- /dev/null
+++ b/packages/core/src/deepagent/rollback-audit-sql.ts
@@ -0,0 +1,33 @@
+import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core"
+
+// V4.0 §D2/§F — durable ROLLBACK audit log. A "rollback" is the moment a human reverts an agent-produced
+// change over a session (via SessionRevert — the same primitive the goal loop uses). The §D2 Rollback
+// surface (paired with the Takeover surface) records these so an operator can see WHEN, on WHICH session,
+// BY WHOM, and WITH WHAT OUTCOME a change was rolled back, and §F exposes the count as `rollback_total`.
+// One append-only row per rollback attempt — this is an audit record (never mutated), kept separate from
+// the mutable Approval Queue because a rollback is an already-happened FACT, not a request awaiting one.
+// Mirrors deepagent_human_takeover exactly, plus an `outcome` column (rollbacks can be a no-op when there
+// is nothing to revert, so the recorded fact carries the result).
+export const RollbackAuditTable = sqliteTable(
+ "deepagent_rollback",
+ {
+ id: text().primaryKey(),
+ workspace_id: text().notNull(),
+ // the session whose agent-produced changes were rolled back. NOT NULL: a rollback always targets a
+ // concrete session (unlike a branch-level takeover, which can have no single session).
+ session_id: text().notNull(),
+ // the human actor who initiated the rollback (routed workspace identity / user id).
+ actor_id: text(),
+ // a short, free-form reason for the §D2 surface.
+ reason: text(),
+ // the result of the SessionRevert call: "reverted" (a revert happened) or "noop" (nothing to revert).
+ outcome: text().notNull(),
+ created_at: integer().notNull(),
+ },
+ (table) => [
+ // §F metric + §D2 surface: a workspace's rollbacks over a window, newest first.
+ index("deepagent_rollback_workspace_idx").on(table.workspace_id, table.created_at),
+ ],
+)
+
+export * as RollbackAuditSql from "./rollback-audit-sql"
diff --git a/packages/core/src/deepagent/rollback-audit.ts b/packages/core/src/deepagent/rollback-audit.ts
new file mode 100644
index 00000000..13e79c54
--- /dev/null
+++ b/packages/core/src/deepagent/rollback-audit.ts
@@ -0,0 +1,129 @@
+export * as RollbackAudit from "./rollback-audit"
+
+import { Context, Effect, Layer } from "effect"
+import { and, desc, eq, gte, lte, sql } from "drizzle-orm"
+import { Database } from "../database/database"
+import { RollbackAuditTable } from "./rollback-audit-sql"
+import { Identifier } from "../util/identifier"
+
+// V4.0 §D2/§F — the Rollback audit service. Records the FACT that a human rolled back an agent-produced
+// change over a session (via SessionRevert) and exposes the count as the §F `rollback_total` metric. This
+// is the backend the §D2 Rollback surface (paired with the Takeover surface) calls to record a rollback,
+// and the observability metric reads. Append-only: a rollback is a past event, not a mutable request — so
+// there is no resolve/update, only record + count/list. Mirrors HumanTakeover exactly, plus `outcome`
+// (a rollback can be a no-op when there is nothing to revert, so the recorded fact carries the result).
+//
+// LAYERING: `core`. Pure durable state; the HTTP/Oversight layer (deepagent-code) calls `record` from the
+// Rollback endpoint (after invoking SessionRevert) and `count` feeds the Observability metric snapshot.
+
+export type RollbackOutcome = "reverted" | "noop"
+
+export interface RollbackRecord {
+ readonly id: string
+ readonly workspaceID: string
+ readonly sessionID: string
+ readonly actorID?: string
+ readonly reason?: string
+ readonly outcome: RollbackOutcome
+ readonly createdAt: number
+}
+
+export interface RecordInput {
+ readonly workspaceID: string
+ readonly sessionID: string
+ readonly actorID?: string
+ readonly reason?: string
+ readonly outcome: RollbackOutcome
+}
+
+export interface Interface {
+ /** §D2 — record a rollback (an already-happened fact). Returns the persisted row. */
+ readonly record: (input: RecordInput) => Effect.Effect
+ /** §D2 — a workspace's rollbacks over [from, to], newest first (the Rollback surface). */
+ readonly list: (input: { workspaceID: string; from: number; to: number }) => Effect.Effect>
+ /** §F `rollback_total` — the count of rollbacks for a workspace over [from, to]. */
+ readonly count: (input: { workspaceID: string; from: number; to: number }) => Effect.Effect
+}
+
+export class Service extends Context.Service()("@deepagent-code/RollbackAudit") {}
+
+export interface LayerOptions {
+ readonly now?: () => number
+}
+
+const decode = (row: {
+ id: string
+ workspace_id: string
+ session_id: string
+ actor_id: string | null
+ reason: string | null
+ outcome: string
+ created_at: number
+}): RollbackRecord => ({
+ id: row.id,
+ workspaceID: row.workspace_id,
+ sessionID: row.session_id,
+ ...(row.actor_id != null ? { actorID: row.actor_id } : {}),
+ ...(row.reason != null ? { reason: row.reason } : {}),
+ outcome: row.outcome === "reverted" ? "reverted" : "noop",
+ createdAt: row.created_at,
+})
+
+export const layerWith = (options?: LayerOptions) =>
+ Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const { db } = yield* Database.Service
+ const now = options?.now ?? Date.now
+
+ const record: Interface["record"] = (input) =>
+ Effect.gen(function* () {
+ const row = {
+ id: "rbk_" + Identifier.ascending(),
+ workspace_id: input.workspaceID,
+ session_id: input.sessionID,
+ actor_id: input.actorID ?? null,
+ reason: input.reason ?? null,
+ outcome: input.outcome,
+ created_at: now(),
+ }
+ yield* db.insert(RollbackAuditTable).values([row]).run().pipe(Effect.orDie)
+ return decode(row)
+ })
+
+ const list: Interface["list"] = (input) =>
+ db
+ .select()
+ .from(RollbackAuditTable)
+ .where(
+ and(
+ eq(RollbackAuditTable.workspace_id, input.workspaceID),
+ gte(RollbackAuditTable.created_at, input.from),
+ lte(RollbackAuditTable.created_at, input.to),
+ ),
+ )
+ .orderBy(desc(RollbackAuditTable.created_at))
+ .all()
+ .pipe(Effect.orDie, Effect.map((rows) => rows.map(decode)))
+
+ const count: Interface["count"] = (input) =>
+ db
+ .select({ n: sql`count(*)` })
+ .from(RollbackAuditTable)
+ .where(
+ and(
+ eq(RollbackAuditTable.workspace_id, input.workspaceID),
+ gte(RollbackAuditTable.created_at, input.from),
+ lte(RollbackAuditTable.created_at, input.to),
+ ),
+ )
+ .get()
+ .pipe(Effect.orDie, Effect.map((r) => r?.n ?? 0))
+
+ return Service.of({ record, list, count })
+ }),
+ )
+
+export const layer = layerWith()
+
+export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
diff --git a/packages/core/src/deepagent/scheduler-sql.ts b/packages/core/src/deepagent/scheduler-sql.ts
new file mode 100644
index 00000000..b412170c
--- /dev/null
+++ b/packages/core/src/deepagent/scheduler-sql.ts
@@ -0,0 +1,56 @@
+import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core"
+
+// V4.0 §A4 — durable persistence for the Scheduler. Unlike `BackgroundJob` (core/src/background-job.ts,
+// EXPLICITLY non-durable — a restart loses live jobs), the V4.0 Scheduler must survive process restarts:
+// a delayed event scheduled before a crash still fires after recovery, and periodic scans resume on
+// their cadence. So schedule entries are rows here, re-hydrated on boot and driven by a tick loop
+// (the loop lives in deepagent-code; this table + the Scheduler service in core own the durable state).
+//
+// Three §A4 kinds share one table (`kind`):
+// delay — fire the templated event once at `fire_at`, then status → fired.
+// periodic — fire every `interval_ms`; on fire, advance `fire_at` and stay active.
+// condition — fire when a threshold of trigger events is observed in a window (e.g. 连续 3 次 CI 失败).
+// `fire_at` is the next re-check time; the condition body lives in `condition` (JSON).
+export const DeepAgentScheduleTable = sqliteTable(
+ "deepagent_schedule",
+ {
+ id: text().primaryKey(),
+ workspace_id: text().notNull(),
+ // delay | periodic | condition
+ kind: text().$type<"delay" | "periodic" | "condition">().notNull(),
+ // active | fired | cancelled. `delay` flips to `fired` after it fires; `periodic`/`condition` stay
+ // `active` until cancelled (a condition may fire repeatedly across its lifetime).
+ status: text().$type<"active" | "fired" | "cancelled">().notNull(),
+ // the PublishInput (minus id/createdAt/idempotencyKey defaults) emitted when this schedule fires.
+ // Stored as JSON; the tick loop hands it to EventBus.publish. idempotencyKey is derived per fire.
+ event_template: text({ mode: "json" }).$type().notNull(),
+ // delay: absolute fire time. periodic: next fire time (advanced on each fire). condition: next
+ // re-check time (nullable ⇒ check every tick).
+ fire_at: integer(),
+ // periodic only: the cadence in ms.
+ interval_ms: integer(),
+ // condition only: JSON { eventType, threshold, windowMs }. See scheduler.ts ConditionSpec.
+ condition: text({ mode: "json" }).$type(),
+ // last time this schedule actually fired (for periodic drift accounting + observability).
+ last_fired_at: integer(),
+ // OPTIONAL stable dedupe key for schedules that must be registered idempotently across restarts
+ // (e.g. the boot-time §A4 bootstrap schedules). NULL for ordinary ad-hoc schedules — and since
+ // SQLite treats NULLs as distinct in a UNIQUE index, the uniqueness below constrains ONLY keyed
+ // rows, leaving every unkeyed schedule free. This closes the multi-process TOCTOU on a list-then-
+ // insert bootstrap: a concurrent second insert of the same key is rejected at the DB layer.
+ schedule_key: text(),
+ created_at: integer().notNull(),
+ updated_at: integer().notNull(),
+ },
+ (table) => [
+ // tick scan: active schedules whose next fire/check time has elapsed, oldest first.
+ index("deepagent_schedule_due_idx").on(table.status, table.fire_at),
+ // per-workspace listing + retention.
+ index("deepagent_schedule_workspace_idx").on(table.workspace_id, table.status),
+ // idempotent bootstrap: at most one row per non-null schedule_key. Unkeyed rows (schedule_key NULL)
+ // are unconstrained (NULLs distinct in SQLite unique indexes) — a natural partial-unique semantics.
+ uniqueIndex("deepagent_schedule_key_uidx").on(table.schedule_key),
+ ],
+)
+
+export * as SchedulerSql from "./scheduler-sql"
diff --git a/packages/core/src/deepagent/scheduler.ts b/packages/core/src/deepagent/scheduler.ts
new file mode 100644
index 00000000..aa62bc15
--- /dev/null
+++ b/packages/core/src/deepagent/scheduler.ts
@@ -0,0 +1,382 @@
+export * as Scheduler from "./scheduler"
+
+import { Context, Effect, Layer } from "effect"
+import { and, asc, eq, lte, or, isNull } from "drizzle-orm"
+import { Database } from "../database/database"
+import { DeepAgentScheduleTable } from "./scheduler-sql"
+import { DeepAgentEvent } from "./deepagent-event"
+import { Identifier } from "../util/identifier"
+
+// V4.0 §A4 — the durable Scheduler service. Owns the `deepagent_schedule` rows and the transitions on
+// them; it does NOT publish events itself (that would couple core to dispatch). The tick loop
+// (deepagent-code) calls `due(now)`, publishes each returned schedule's `eventTemplate` through the
+// Event Bus, then calls `markFired`/`recheckCondition` to advance state. This keeps core pure of the
+// runtime while the durable state (survives restarts, unlike BackgroundJob) lives here.
+//
+// LAYERING: `core`. No dispatch / session / RuntimeFlags imports.
+
+// The event a schedule emits when it fires — a PublishInput without the bus-filled defaults.
+export type EventTemplate = Omit
+
+// §A4 条件触发: fire when ≥ `threshold` events of `eventType` are observed within `windowMs`
+// (e.g. 连续 3 次 CI 失败 → 修复 Goal). Evaluation is delegated to `conditionMet` (pure) against the
+// Event Bus `recentByType` count; the scheduler only stores the spec + re-check cadence.
+export interface ConditionSpec {
+ readonly eventType: string
+ readonly threshold: number
+ readonly windowMs: number
+ // §A4 跨 workspace 计数 — when true, the tick loop counts trigger events across ALL workspaces (it
+ // omits the workspaceID filter in recentByType), so a SYSTEM-level condition (e.g. the "3× CI failure
+ // → repair" trigger registered in the system workspace) can observe CI failures that land in per-
+ // project workspaces. Omitted/false ⇒ the historical behavior: count only within the schedule's own
+ // workspace. Fail-safe: an existing condition row (no flag) is unchanged.
+ readonly crossWorkspace?: boolean
+ // §C3.2 / P4.5b 按 repo 分组 — when true, the tick PARTITIONS the recent trigger events by their
+ // `payload.repo` discriminator and evaluates the threshold PER REPO, firing ONE templated event per
+ // repo that independently meets it — with `repo` stamped into the fired event's payload (and its
+ // workspaceID scoped to that repo's failing workspace). This makes the "3× CI failure → repair"
+ // trigger PER REPO: a repair is scoped to the repo that actually failed 3×, not a global counter that
+ // conflates independent repos (the P1.6-review defect). Composes with `crossWorkspace`: crossWorkspace
+ // spans the count across the per-project workspaces CI failures land in, then groupByRepo partitions
+ // that cross-tenant stream by repo. Trigger events with no string `payload.repo` are ignored (they
+ // can't be repo-scoped). Omitted/false ⇒ the historical single-counter behavior (unchanged).
+ readonly groupByRepo?: boolean
+}
+
+export type ScheduleKind = "delay" | "periodic" | "condition"
+export type ScheduleStatus = "active" | "fired" | "cancelled"
+
+export interface Schedule {
+ readonly id: string
+ readonly workspaceID: string
+ readonly kind: ScheduleKind
+ readonly status: ScheduleStatus
+ readonly eventTemplate: EventTemplate
+ readonly fireAt?: number
+ readonly intervalMs?: number
+ readonly condition?: ConditionSpec
+ readonly lastFiredAt?: number
+ // the stable dedupe key (schedule_key column), when this row was registered with one.
+ readonly scheduleKey?: string
+}
+
+// `scheduleKey` (all three inputs): an OPTIONAL stable dedupe identity. When set, the row carries it in
+// the `schedule_key` column, which has a partial-unique index (NULLs distinct) — so a second insert of
+// the same key is rejected at the DB layer and the service swallows the conflict (onConflictDoNothing).
+// This makes boot-time idempotent registration safe even under a multi-process TOCTOU. Omit for ad-hoc
+// schedules (no dedupe).
+export interface ScheduleDelayInput {
+ readonly workspaceID: string
+ readonly fireAt: number
+ readonly eventTemplate: EventTemplate
+ readonly scheduleKey?: string
+}
+export interface SchedulePeriodicInput {
+ readonly workspaceID: string
+ readonly intervalMs: number
+ readonly firstFireAt: number
+ readonly eventTemplate: EventTemplate
+ readonly scheduleKey?: string
+}
+export interface ScheduleConditionInput {
+ readonly workspaceID: string
+ readonly condition: ConditionSpec
+ readonly recheckEveryMs?: number // next re-check cadence; omit ⇒ eligible every tick
+ readonly firstCheckAt: number
+ readonly eventTemplate: EventTemplate
+ readonly scheduleKey?: string
+}
+
+// §A4 条件触发 — PURE evaluator. `recentCount` is the number of matching events the caller counted via
+// EventBus.recentByType({type: spec.eventType, windowMs: spec.windowMs, workspaceID}). Kept pure so the
+// threshold logic is unit-testable without a bus/db.
+export const conditionMet = (spec: ConditionSpec, recentCount: number): boolean => recentCount >= spec.threshold
+
+export interface Interface {
+ /** §A4 延迟事件 — fire the templated event once at `fireAt`. */
+ readonly scheduleDelay: (input: ScheduleDelayInput) => Effect.Effect
+ /** §A4 周期扫描 — fire every `intervalMs`, starting at `firstFireAt`, until cancelled. */
+ readonly schedulePeriodic: (input: SchedulePeriodicInput) => Effect.Effect
+ /** §A4 条件触发 — fire when `condition` is met at a re-check; stays active for repeated firing. */
+ readonly scheduleCondition: (input: ScheduleConditionInput) => Effect.Effect
+ /** Active schedules whose next fire/check time (`fireAt`) is ≤ now (null fireAt ⇒ always due). */
+ readonly due: (now: number) => Effect.Effect>
+ /**
+ * Record that a schedule fired at `firedAt`. delay → status fired. periodic → advance fireAt by
+ * intervalMs (catching up past `firedAt` so a slow tick doesn't fire a burst) and stay active.
+ * condition → stay active; caller sets the next recheck via `recheckCondition`.
+ */
+ readonly markFired: (id: string, firedAt: number) => Effect.Effect
+ /** condition schedules: set the next re-check time after an evaluation that did NOT fire. */
+ readonly recheckCondition: (id: string, nextCheckAt: number) => Effect.Effect
+ /** Cancel a schedule (idempotent). */
+ readonly cancel: (id: string) => Effect.Effect
+ /** List schedules for a workspace (active by default). */
+ readonly list: (workspaceID: string, status?: ScheduleStatus) => Effect.Effect>
+}
+
+export class Service extends Context.Service()("@deepagent-code/DeepAgentScheduler") {}
+
+export interface LayerOptions {
+ readonly now?: () => number
+}
+
+const decode = (row: {
+ id: string
+ workspace_id: string
+ kind: string
+ status: string
+ event_template: unknown
+ fire_at: number | null
+ interval_ms: number | null
+ condition: unknown
+ last_fired_at: number | null
+ schedule_key?: string | null
+}): Schedule => ({
+ id: row.id,
+ workspaceID: row.workspace_id,
+ kind: row.kind as ScheduleKind,
+ status: row.status as ScheduleStatus,
+ eventTemplate: row.event_template as EventTemplate,
+ ...(row.fire_at != null ? { fireAt: row.fire_at } : {}),
+ ...(row.interval_ms != null ? { intervalMs: row.interval_ms } : {}),
+ ...(row.condition != null ? { condition: row.condition as ConditionSpec } : {}),
+ ...(row.last_fired_at != null ? { lastFiredAt: row.last_fired_at } : {}),
+ ...(row.schedule_key != null ? { scheduleKey: row.schedule_key } : {}),
+})
+
+export const layerWith = (options?: LayerOptions) =>
+ Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const { db } = yield* Database.Service
+ const now = options?.now ?? Date.now
+ const newID = () => "sch_" + Identifier.ascending()
+
+ // Unkeyed insert: no dedupe, return the row we wrote (the historical behavior).
+ const insert = (values: typeof DeepAgentScheduleTable.$inferInsert) =>
+ db
+ .insert(DeepAgentScheduleTable)
+ .values([values])
+ .run()
+ .pipe(Effect.orDie, Effect.as(decode(values as Parameters[0])))
+
+ // Keyed insert: idempotent on `schedule_key`. `onConflictDoNothing` makes a duplicate insert a
+ // no-op at the DB layer (closing the multi-process TOCTOU that a list-then-insert guard cannot),
+ // then we re-read the CANONICAL row by key so a race-loser returns the WINNER's row (its real id),
+ // not the phantom values it tried to insert. Existing rows may predate the column and thus carry a
+ // key already, so this also dedupes an ordinary re-registration.
+ const insertKeyed = (values: typeof DeepAgentScheduleTable.$inferInsert, key: string) =>
+ Effect.gen(function* () {
+ yield* db
+ .insert(DeepAgentScheduleTable)
+ .values([values])
+ .onConflictDoNothing({ target: DeepAgentScheduleTable.schedule_key })
+ .run()
+ .pipe(Effect.orDie)
+ const winner = yield* db
+ .select()
+ .from(DeepAgentScheduleTable)
+ .where(eq(DeepAgentScheduleTable.schedule_key, key))
+ .get()
+ .pipe(Effect.orDie)
+ // winner is always present (we either inserted it or a concurrent writer did).
+ return decode((winner ?? values) as Parameters[0])
+ })
+
+ // Route to the keyed or unkeyed path based on whether a scheduleKey was supplied.
+ const insertMaybeKeyed = (values: typeof DeepAgentScheduleTable.$inferInsert) =>
+ values.schedule_key != null ? insertKeyed(values, values.schedule_key) : insert(values)
+
+ const scheduleDelay: Interface["scheduleDelay"] = (input) => {
+ const at = now()
+ return insertMaybeKeyed({
+ id: newID(),
+ workspace_id: input.workspaceID,
+ kind: "delay",
+ status: "active",
+ event_template: input.eventTemplate,
+ fire_at: input.fireAt,
+ interval_ms: null,
+ condition: null,
+ last_fired_at: null,
+ schedule_key: input.scheduleKey ?? null,
+ created_at: at,
+ updated_at: at,
+ })
+ }
+
+ const schedulePeriodic: Interface["schedulePeriodic"] = (input) => {
+ // A non-positive interval would never advance fire_at (interval 0) or move it backward
+ // (negative) → the schedule would hot-refire every tick forever. Reject at creation (a caller
+ // bug, not a recoverable condition — consistent with the module's orDie discipline).
+ if (!Number.isFinite(input.intervalMs) || input.intervalMs <= 0)
+ return Effect.die(new Error(`schedulePeriodic: intervalMs must be a positive number, got ${input.intervalMs}`))
+ const at = now()
+ return insertMaybeKeyed({
+ id: newID(),
+ workspace_id: input.workspaceID,
+ kind: "periodic",
+ status: "active",
+ event_template: input.eventTemplate,
+ fire_at: input.firstFireAt,
+ interval_ms: input.intervalMs,
+ condition: null,
+ last_fired_at: null,
+ schedule_key: input.scheduleKey ?? null,
+ created_at: at,
+ updated_at: at,
+ })
+ }
+
+ const scheduleCondition: Interface["scheduleCondition"] = (input) => {
+ // recheckEveryMs is the condition's cadence; if supplied it must be positive for the same
+ // reason as periodic's interval. Omitting it means "re-check every tick" (interval null).
+ if (input.recheckEveryMs != null && (!Number.isFinite(input.recheckEveryMs) || input.recheckEveryMs <= 0))
+ return Effect.die(
+ new Error(`scheduleCondition: recheckEveryMs must be a positive number when set, got ${input.recheckEveryMs}`),
+ )
+ const at = now()
+ return insertMaybeKeyed({
+ id: newID(),
+ workspace_id: input.workspaceID,
+ kind: "condition",
+ status: "active",
+ event_template: input.eventTemplate,
+ fire_at: input.firstCheckAt,
+ interval_ms: input.recheckEveryMs ?? null,
+ condition: input.condition,
+ last_fired_at: null,
+ schedule_key: input.scheduleKey ?? null,
+ created_at: at,
+ updated_at: at,
+ })
+ }
+
+ const due: Interface["due"] = (nowArg) =>
+ db
+ .select()
+ .from(DeepAgentScheduleTable)
+ .where(
+ and(
+ eq(DeepAgentScheduleTable.status, "active"),
+ // null fire_at ⇒ always eligible (condition checked every tick); else fire_at <= now.
+ or(isNull(DeepAgentScheduleTable.fire_at), lte(DeepAgentScheduleTable.fire_at, nowArg)),
+ ),
+ )
+ .orderBy(asc(DeepAgentScheduleTable.fire_at))
+ .all()
+ .pipe(Effect.orDie, Effect.map((rows) => rows.map(decode)))
+
+ // §A4 — record a fire. The select-then-update runs in an immediate transaction wrapped in
+ // `Effect.uninterruptible` so a concurrent `cancel` can't slip between the two: without this a
+ // tick's markFired could read status=active, yield, and then overwrite a `cancel` that committed
+ // in the gap — firing a schedule the user cancelled. Every UPDATE also re-asserts status='active'
+ // in its WHERE so, even under the txn, a lost-cancel can never resurrect a terminal row.
+ const markFired: Interface["markFired"] = (id, firedAt) =>
+ Effect.uninterruptible(
+ db
+ .transaction(
+ () =>
+ Effect.gen(function* () {
+ const row = yield* db
+ .select()
+ .from(DeepAgentScheduleTable)
+ .where(eq(DeepAgentScheduleTable.id, id))
+ .get()
+ .pipe(Effect.orDie)
+ if (!row || row.status !== "active") return
+ const at = now()
+ const active = and(
+ eq(DeepAgentScheduleTable.id, id),
+ eq(DeepAgentScheduleTable.status, "active"),
+ )
+ if (row.kind === "delay") {
+ yield* db
+ .update(DeepAgentScheduleTable)
+ .set({ status: "fired", last_fired_at: firedAt, updated_at: at })
+ .where(active)
+ .run()
+ .pipe(Effect.orDie)
+ return
+ }
+ // periodic AND condition both advance fire_at past firedAt by their cadence so a
+ // fire is not immediately re-eligible next tick. periodic's cadence is interval_ms;
+ // a condition's cadence is its recheck interval (interval_ms, from recheckEveryMs).
+ // Catch-up (while next <= firedAt) means a slow/backlogged tick fires ONCE and
+ // resumes cadence rather than emitting a burst of missed ticks. `interval > 0` is
+ // guaranteed at creation, so the loop terminates.
+ const interval = row.interval_ms ?? 0
+ if (interval > 0) {
+ let next = (row.fire_at ?? firedAt) + interval
+ while (next <= firedAt) next += interval
+ yield* db
+ .update(DeepAgentScheduleTable)
+ .set({ fire_at: next, last_fired_at: firedAt, updated_at: at })
+ .where(active)
+ .run()
+ .pipe(Effect.orDie)
+ return
+ }
+ // No cadence (a condition created without recheckEveryMs = "re-check every tick").
+ // fire_at stays in the past, so the row is due again next tick — INTENTIONAL for
+ // every-tick evaluation, but the caller MUST then `recheckCondition` or `cancel`
+ // after acting on a fire, or it will re-fire each tick until the window drains.
+ yield* db
+ .update(DeepAgentScheduleTable)
+ .set({ last_fired_at: firedAt, updated_at: at })
+ .where(active)
+ .run()
+ .pipe(Effect.orDie)
+ }),
+ { behavior: "immediate" },
+ )
+ .pipe(Effect.orDie),
+ )
+
+ const recheckCondition: Interface["recheckCondition"] = (id, nextCheckAt) =>
+ db
+ .update(DeepAgentScheduleTable)
+ .set({ fire_at: nextCheckAt, updated_at: now() })
+ .where(and(eq(DeepAgentScheduleTable.id, id), eq(DeepAgentScheduleTable.status, "active")))
+ .run()
+ .pipe(Effect.orDie, Effect.asVoid)
+
+ const cancel: Interface["cancel"] = (id) =>
+ db
+ .update(DeepAgentScheduleTable)
+ .set({ status: "cancelled", updated_at: now() })
+ .where(eq(DeepAgentScheduleTable.id, id))
+ .run()
+ .pipe(Effect.orDie, Effect.asVoid)
+
+ const list: Interface["list"] = (workspaceID, status) =>
+ db
+ .select()
+ .from(DeepAgentScheduleTable)
+ .where(
+ and(
+ eq(DeepAgentScheduleTable.workspace_id, workspaceID),
+ eq(DeepAgentScheduleTable.status, status ?? "active"),
+ ),
+ )
+ .orderBy(asc(DeepAgentScheduleTable.fire_at))
+ .all()
+ .pipe(Effect.orDie, Effect.map((rows) => rows.map(decode)))
+
+ return Service.of({
+ scheduleDelay,
+ schedulePeriodic,
+ scheduleCondition,
+ due,
+ markFired,
+ recheckCondition,
+ cancel,
+ list,
+ })
+ }),
+ )
+
+export const layer = layerWith()
+
+export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
diff --git a/packages/core/src/deepagent/security-gate.ts b/packages/core/src/deepagent/security-gate.ts
new file mode 100644
index 00000000..2c092854
--- /dev/null
+++ b/packages/core/src/deepagent/security-gate.ts
@@ -0,0 +1,88 @@
+export * as SecurityGate from "./security-gate"
+
+import { DeepAgentEvent } from "./deepagent-event"
+import type { AgentDescriptor } from "../im/mention-parser"
+
+// V4.0 §E1 — the four-layer permission GATE POLICY. This is a PURE, deterministic decision function:
+// given the RESOLVED facts about an event (its source's trust tier, the actor's workspace/project
+// permission, the agent's declared capabilities, and the tool/session runtime verdict), it runs the
+// four checks IN ORDER and fail-closes on the FIRST failure.
+//
+// LAYERING: lives in `core` and imports NOTHING runtime. This module does NO IO — the caller (the
+// deepagent-code wiring) resolves each fact (is the source trusted? does the actor hold the perm? did
+// the runtime allow the op?) and passes booleans in, so this stays a pure, unit-testable policy with no
+// Effect, no DB, no permission-store import. The wiring resolves the facts, calls `check`, and either
+// proceeds or surfaces the `{failedLayer, reason}` fail-closed verdict.
+//
+// §E1 责任, mapped to the four layers (ALL must pass; any failure = fail closed):
+// 1. event_source : is the event's origin system in the trusted set?
+// 2. actor_permission : does the acting user/agent hold the workspace/project permission?
+// 3. agent_capability : if a capability is required, does the agent declare it?
+// 4. runtime_operation: does the tool/session runtime allow the ACTUAL operation about to run?
+
+// The four checks, in the ORDER they run. Failing earlier = shorter blast radius revealed to the caller.
+export type SecurityLayer =
+ | "event_source"
+ | "actor_permission"
+ | "agent_capability"
+ | "runtime_operation"
+
+// The verdict. `allowed:true` only when all four layers pass; otherwise the first failed layer + reason.
+export type SecurityDecision =
+ | { readonly allowed: true }
+ | { readonly allowed: false; readonly failedLayer: SecurityLayer; readonly reason: string }
+
+export interface SecurityInput {
+ // §E1 layer 1 — resolved by the caller from the event's source + the workspace's trusted-source list.
+ readonly eventSourceTrusted: boolean
+ // §E1 layer 2 — resolved by the caller against the workspace/project ACL for the acting user/agent.
+ readonly actorHasPermission: boolean
+ // §E1 layer 3 — the agent's declared capabilities (AgentDescriptor.capabilities projection).
+ readonly agentCapabilities: ReadonlyArray
+ // the capability this operation requires. When omitted, layer 3 is a no-op (nothing required).
+ readonly requiredCapability?: string
+ // §E1 layer 4 — resolved by the caller from the tool/session runtime for the ACTUAL operation.
+ readonly runtimeAllowed: boolean
+}
+
+/**
+ * §E1 — the pure four-layer permission check. Runs the layers in order and fail-closes on the FIRST
+ * failure, returning that layer and a reason. Returns `{allowed:true}` only when every layer passes:
+ * 1. event_source → fails if !eventSourceTrusted.
+ * 2. actor_permission → fails if !actorHasPermission.
+ * 3. agent_capability → fails if requiredCapability is set AND not in agentCapabilities.
+ * 4. runtime_operation → fails if !runtimeAllowed.
+ */
+export const check = (input: SecurityInput): SecurityDecision => {
+ if (!input.eventSourceTrusted) {
+ return { allowed: false, failedLayer: "event_source", reason: "event source is not trusted" }
+ }
+
+ if (!input.actorHasPermission) {
+ return { allowed: false, failedLayer: "actor_permission", reason: "actor lacks workspace/project permission" }
+ }
+
+ if (input.requiredCapability != null && !input.agentCapabilities.includes(input.requiredCapability)) {
+ return {
+ allowed: false,
+ failedLayer: "agent_capability",
+ reason: `agent lacks required capability: ${input.requiredCapability}`,
+ }
+ }
+
+ if (!input.runtimeAllowed) {
+ return { allowed: false, failedLayer: "runtime_operation", reason: "runtime denied the operation" }
+ }
+
+ return { allowed: true }
+}
+
+// §E1 layer-1 helper — is the event's source in the workspace's trusted-source set? Pure set membership.
+export const isTrustedSource = (
+ source: DeepAgentEvent.EventSource,
+ trusted: ReadonlyArray,
+): boolean => trusted.includes(source)
+
+// §E1 layer-3 helper — does the agent descriptor declare `cap`? Treats a missing list as empty.
+export const hasCapability = (descriptor: Pick, cap: string): boolean =>
+ (descriptor.capabilities ?? []).includes(cap)
diff --git a/packages/core/src/deepagent/security-resolvers.ts b/packages/core/src/deepagent/security-resolvers.ts
new file mode 100644
index 00000000..662a9d9b
--- /dev/null
+++ b/packages/core/src/deepagent/security-resolvers.ts
@@ -0,0 +1,168 @@
+export * as SecurityResolvers from "./security-resolvers"
+
+import { Context, Effect, Layer } from "effect"
+import { DeepAgentEvent } from "./deepagent-event"
+import { WorkspaceConfig } from "./workspace-config"
+import { AgentListProviderService } from "../im/agent-list-provider"
+import { IMRepository } from "../im/repository"
+import type { AgentDescriptor } from "../im/mention-parser"
+
+// V4.0 §E1 — the RESOLVERS that turn the pure SecurityGate policy (security-gate.ts) into a production
+// decision. SecurityGate.check is deliberately fact-free: it takes booleans and returns a fail-closed
+// verdict. Something has to RESOLVE those facts from real state (workspace config, IM membership, the
+// agent registry, the agent's declared limits). That is this module. The MultiAgentRuntime today wires
+// LENIENT allow-defaults (trustedSources = all, actorHasPermission = () => true, runtimeAllowed =
+// () => true); these resolvers are the PRODUCTION replacements it can inject instead.
+//
+// LAYERING: lives in `core`. It DOES do IO (config read, membership lookup, registry lookup) — that is
+// the whole point; the pure policy stays in security-gate.ts. Deps: WorkspaceConfig + AgentListProvider
+// + IMRepository. Every method FAILS CLOSED: a lookup error resolves to "not trusted / not permitted /
+// not allowed", never open.
+//
+// §E1 layers this module resolves (layer 3 — agent_capability — is already pure in security-gate.ts):
+// layer 1 event_source → resolveTrustedSources(workspaceID) reads WorkspaceConfig.trustedSources
+// layer 2 actor_permission → actorHasWorkspacePermission({...}) IM membership OR agent registry
+// layer 4 runtime_operation→ runtimeAllowsOperation({...}) coarse agent-limit pre-gate
+
+// ─── PURE helper units (no IO — directly unit-testable) ──────────────────────────────────────────────
+
+/**
+ * §E1 layer-4 pure core — does the agent's declared `toolWhitelist` permit `capability`?
+ *
+ * An agent with NO declared whitelist (`limits.toolWhitelist` unset) imposes NO extra restriction here
+ * (returns true) — the child session's own permission path remains the fine-grained enforcement; this
+ * gate is defense-in-depth only. When a whitelist IS declared, a capability outside it is denied. A
+ * missing/omitted `capability` is a no-op (nothing specific is being gated) → allowed.
+ */
+export const capabilityWithinDeclaredTools = (
+ agent: Pick,
+ capability?: string,
+): boolean => {
+ const whitelist = agent.limits?.toolWhitelist
+ if (whitelist == null) return true // no declared restriction — kernel/session permissions apply
+ if (capability == null) return true // nothing specific to gate
+ return whitelist.includes(capability)
+}
+
+// ─── The service ─────────────────────────────────────────────────────────────────────────────────────
+
+export interface ActorPermissionInput {
+ readonly workspaceID: string
+ // absent ⇒ a system / no-actor event (see the no-actor policy on `actorHasWorkspacePermission`).
+ readonly actorID?: string
+ // the acting agent, if the event is bound to one. Used for the "agent is registered for the
+ // workspace" arm of the OR rule.
+ readonly agentID?: string
+}
+
+export interface RuntimeOperationInput {
+ readonly workspaceID: string
+ readonly agent: Pick
+ // the capability/tool the operation requires; omitted ⇒ nothing specific to gate (allowed).
+ readonly capability?: string
+}
+
+export interface Interface {
+ /**
+ * §E1 layer 1 — the workspace's trusted event sources (defaults applied by WorkspaceConfig). Feed the
+ * result to SecurityGate.isTrustedSource(event.source, …). Never fails (config.get never fails).
+ */
+ readonly resolveTrustedSources: (
+ workspaceID: string,
+ ) => Effect.Effect>
+
+ /**
+ * §E1 layer 2 — is the actor permitted in this workspace? PRODUCTION rule (fail-closed):
+ * permitted ⇔ the actor is a MEMBER of at least one of the workspace's IM groups
+ * OR the acting agent (`agentID`) is REGISTERED/visible for the workspace.
+ * NO-ACTOR POLICY: when `actorID` is absent the event is a system/no-actor event; those are NOT gated
+ * by workspace membership (there is no member to check) — their trust is established at LAYER 1
+ * (event_source), which runs BEFORE this layer and must already have passed for a system event to
+ * reach here. So a no-actor event resolves to `true` here, deferring its gating to layer 1. Any
+ * lookup ERROR resolves to `false` (fail closed), never open.
+ */
+ readonly actorHasWorkspacePermission: (input: ActorPermissionInput) => Effect.Effect
+
+ /**
+ * §E1 layer 4 — coarse pre-gate: does the agent's declared limits allow this operation? Denies when a
+ * `toolWhitelist` is declared and `capability` is outside it (see `capabilityWithinDeclaredTools`).
+ * The child session's own permission path is the fine-grained enforcement; this is defense-in-depth.
+ * Never fails; pure over the passed agent.
+ */
+ readonly runtimeAllowsOperation: (input: RuntimeOperationInput) => Effect.Effect
+}
+
+export class Service extends Context.Service()("@deepagent-code/SecurityResolvers") {}
+
+export const layer = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const config = yield* WorkspaceConfig.Service
+ const agentList = yield* AgentListProviderService
+ const im = yield* IMRepository
+
+ const resolveTrustedSources: Interface["resolveTrustedSources"] = (workspaceID) =>
+ config.get(workspaceID).pipe(Effect.map((r) => r.trustedSources))
+
+ const actorHasWorkspacePermission: Interface["actorHasWorkspacePermission"] = (input) =>
+ Effect.gen(function* () {
+ // no-actor (system) event: not membership-gated — its trust is layer 1's job. Deferring to
+ // layer 1 (which already passed for this event to reach layer 2) rather than opening blindly.
+ if (input.actorID == null) return true
+
+ // arm 1: the actor is a member of some IM group in this workspace. listGroups already filters to
+ // groups where the given member_id is a member, so a non-empty result == "is a workspace member".
+ const isMember = yield* im
+ .listGroups({ workspaceID: input.workspaceID, userID: input.actorID })
+ .pipe(
+ Effect.map((groups) => groups.length > 0),
+ Effect.catch(() => Effect.succeed(false)), // lookup error ⇒ fail closed
+ )
+ if (isMember) return true
+
+ // arm 2: the acting agent is registered/visible for this workspace.
+ if (input.agentID == null) return false
+ const agentID = input.agentID
+ return yield* agentList
+ .listAgents({ workspaceID: input.workspaceID, userID: input.actorID })
+ .pipe(
+ Effect.map((agents) => agents.some((a) => a.id === agentID || a.name === agentID)),
+ Effect.catch(() => Effect.succeed(false)), // lookup error ⇒ fail closed
+ )
+ })
+
+ const runtimeAllowsOperation: Interface["runtimeAllowsOperation"] = (input) =>
+ Effect.succeed(capabilityWithinDeclaredTools(input.agent, input.capability))
+
+ return Service.of({ resolveTrustedSources, actorHasWorkspacePermission, runtimeAllowsOperation })
+ }),
+)
+
+// ─── INJECTION NOTE (how MultiAgentRuntime should consume this — NOT wired here) ─────────────────────
+//
+// MultiAgentRuntime.layerWith takes LENIENT defaults today (multi-agent-runtime.ts):
+// trustedSources?: ReadonlyArray // default: all trusted
+// actorHasPermission?: (event, agent: AgentDescriptor) => Effect // default: () => true
+// runtimeAllowed?: (event, agent: AgentDescriptor) => Effect // default: () => true
+//
+// The integration wiring (which OWNS multi-agent-runtime.ts) provides a SecurityResolvers.Service and
+// passes adapters that close over it. Because the runtime resolves trustedSources per-workspace, the
+// cleanest wiring resolves it inside the actor/runtime adapters (or the wiring precomputes it):
+//
+// const sec = yield* SecurityResolvers.Service
+// MultiAgentRuntime.layerWith({
+// runner,
+// // layer 1 — omit the static option and resolve per-event instead, OR precompute for a known ws.
+// actorHasPermission: (event, agent) =>
+// sec.actorHasWorkspacePermission({ workspaceID: event.workspaceID, actorID: event.actorID, agentID: agent.id }),
+// runtimeAllowed: (event, agent) =>
+// sec.runtimeAllowsOperation({ workspaceID: event.workspaceID, agent /*, capability: subtask.capability */ }),
+// })
+//
+// For layer 1, since layerWith's `trustedSources` is a static array (not per-event), the wiring either
+// (a) resolves sec.resolveTrustedSources(workspaceID) once for a single-workspace runtime and passes the
+// array, or (b) the runtime is extended (integration's call, not this track's) to resolve it per-event.
+// Note `capability` for layer 4: the runtime's `runtimeAllowed` signature is (event, agent) with no
+// capability; the coarse pre-gate here still functions on the agent's declared whitelist, and the
+// wiring may close over the subtask capability when it has it. Fine-grained per-tool enforcement remains
+// the child session's permission path — this resolver is defense-in-depth.
diff --git a/packages/core/src/deepagent/session-state.ts b/packages/core/src/deepagent/session-state.ts
index 885733bd..a9fea9e0 100644
--- a/packages/core/src/deepagent/session-state.ts
+++ b/packages/core/src/deepagent/session-state.ts
@@ -62,6 +62,12 @@ export type SessionRunState = {
// client round-trip guess). Armed means the user may convene a panel (button press) and — when a goal
// loop is running — the loop may convene the panel at high-risk decision points (§C activation).
panelArmed: boolean | null
+ // V4.0: the DEBATE DEPTH preference for on-demand convenes from the composer's three-state control
+ // (Off / Single-round / Multi-round). Decoupled from `panelArmed` (arm/disarm) on purpose: arming
+ // gates WHETHER a panel may convene; this gates HOW MANY rounds a convene runs. `null` = never chosen
+ // → defaults to "single". Replaces the former Shift/Alt-click gesture with an explicit, discoverable
+ // choice that PERSISTS per session. "multi" requests up to PANEL_MAX_ROUNDS_CEILING debate rounds.
+ panelRounds: "single" | "multi" | null
// V3.9 §D: a lightweight pointer to the goal currently driven for this session (the authoritative
// loop state lives in the DocumentStore run_context doc — this is only enough to find/resume it and
// reflect its phase in the UI). Null when no goal is running.
@@ -90,6 +96,12 @@ const sessions = new Map()
export const configure = (dir: string) => {
stateDir = dir
mkdirSync(dir, { recursive: true })
+ // Pointing at a (new) state dir means a fresh session set: clear the in-memory map BEFORE loading, so
+ // configure() reflects exactly what's on disk at `dir` and never merges stale sessions from a prior
+ // dir. Production calls configure once at gateway init (nothing to lose); tests that configure a fresh
+ // tmp dir per case were previously polluted by in-memory sessions surviving across cases/files
+ // (loadFromDisk only ADDED entries, never reset), making id-keyed state (e.g. grace counters) leak.
+ sessions.clear()
loadFromDisk()
}
@@ -113,6 +125,7 @@ export const getOrCreate = (sessionId: string, mode: AgentMode): SessionRunState
mutationsSinceReport: 0,
validationPassedSinceReport: false,
panelArmed: null,
+ panelRounds: null,
activeGoal: null,
createdAt: new Date().toISOString(),
completedAt: null,
@@ -226,6 +239,18 @@ export const resolvePanelArmed = (sessionId: string, globalDefault: boolean): bo
/** Back-compat: effective armed state with a hard `false` fallback (no global default available). */
export const isPanelArmed = (sessionId: string): boolean => sessions.get(sessionId)?.panelArmed ?? false
+// V4.0 — Expert Panel debate-depth preference (composer three-state control). Decoupled from arming.
+export const setPanelRounds = (sessionId: string, rounds: "single" | "multi"): void => {
+ const state = sessions.get(sessionId)
+ if (!state) return
+ state.panelRounds = rounds
+ saveToDisk()
+}
+
+/** The chosen debate depth, defaulting to "single" when never explicitly chosen. */
+export const panelRounds = (sessionId: string): "single" | "multi" =>
+ sessions.get(sessionId)?.panelRounds ?? "single"
+
// V3.9 §D — active-goal pointer. The GoalLoop status doc in the DocumentStore is authoritative; this
// pointer is the session-local index the server/UI use to find and reflect the running goal.
export const setActiveGoal = (sessionId: string, pointer: ActiveGoalPointer | null): void => {
@@ -394,6 +419,8 @@ function normalizeState(state: SessionRunState): SessionRunState {
// Backfill: sessions persisted before V3.9 §C/§D have neither slot on disk. panelArmed backfills to
// null (= not explicitly toggled → follows the global default), NOT false.
panelArmed: state.panelArmed ?? null,
+ // Backfill: sessions persisted before V4.0 have no panelRounds → null (defaults to "single").
+ panelRounds: state.panelRounds ?? null,
activeGoal: state.activeGoal ?? null,
}
sessions.set(state.sessionId, normalized)
diff --git a/packages/core/src/deepagent/task-partitioner.ts b/packages/core/src/deepagent/task-partitioner.ts
new file mode 100644
index 00000000..2214ee44
--- /dev/null
+++ b/packages/core/src/deepagent/task-partitioner.ts
@@ -0,0 +1,194 @@
+export * as TaskPartitioner from "./task-partitioner"
+
+import { DeepAgentEvent } from "./deepagent-event"
+import type { AgentDescriptor } from "../im/mention-parser"
+import { AutonomyPolicy } from "./autonomy-policy"
+import { Identifier } from "../util/identifier"
+
+// V4.0 §C2 — the Task Partitioner. A PURE decomposition: given an event and the available agents, it
+// splits a complex event into an ordered set of subtasks, each DECLARING (§C2 contract) its
+// dependencies, file scope, required capabilities, and approval level. It does NOT run anything — the
+// Multi-Agent Runtime (deepagent-code) takes this plan and schedules the subtasks (respecting deps +
+// the Conflict Arbiter). Kept pure (no Effect/DB) so decomposition is deterministic + unit-testable.
+//
+// The mapping from event → subtasks is RULE-DRIVEN (a declarative table keyed by event type), so new
+// event kinds add a rule rather than code. A subtask names its agent by REQUIRED CAPABILITY, not a
+// concrete agent id — the runtime binds a capable agent from the registry at schedule time (so the plan
+// survives registry changes). §C2 examples encoded as the default rules:
+// ci.failure → CodeFix (code_edit) then TestAgent (test_run), test depends on the fix.
+// pr.comment(perf)→ Perf analyze → Code change → Review, a linear pipeline.
+// monitor.alert → Diagnosis (locate) then CodeFix (propose), propose depends on diagnosis.
+//
+// LAYERING: `core`. No runtime imports. Reuses AutonomyPolicy for the per-subtask approval level.
+
+// A subtask id — `tsk_` prefix, ascending-monotonic (stable ordering for debugging).
+export const newTaskID = (at?: number): string => "tsk_" + Identifier.create(false, at)
+
+// One decomposed unit of work. `dependsOn` references other subtasks BY their `id` within the same
+// partition (a DAG; the runtime topologically schedules). `capability` is what the executing agent must
+// have (§C2 "所需能力"). `fileScope` is the declared write scope (§C2 "文件范围" — feeds the Conflict
+// Arbiter's branch/lock isolation). `requiredAutonomy` is the minimum autonomy level to execute it
+// (§C2 "审批等级" → §D gate).
+export interface Subtask {
+ readonly id: string
+ readonly capability: string
+ // human-facing intent, e.g. "fix failing tests", "add regression test".
+ readonly intent: string
+ readonly dependsOn: ReadonlyArray
+ // declared write scope: glob-ish paths this subtask expects to modify (may be empty = unknown/broad).
+ readonly fileScope: ReadonlyArray
+ readonly requiredAutonomy: AutonomyPolicy.ActionRisk
+}
+
+export interface Partition {
+ readonly event: DeepAgentEvent.Event
+ readonly subtasks: ReadonlyArray
+}
+
+// A partition RULE step: a capability + intent + which prior steps (by index within the rule) it
+// depends on + its autonomy requirement. `fileScope` is derived from the event payload by `scopeOf`.
+interface RuleStep {
+ readonly capability: string
+ readonly intent: string
+ readonly dependsOnIdx: ReadonlyArray
+ readonly requiredAutonomy: AutonomyPolicy.ActionRisk
+}
+
+// Matches an event type (exact or `prefix.*`, mirroring EventRouter.matches semantics) to an ordered
+// list of rule steps.
+interface PartitionRule {
+ readonly match: string
+ readonly steps: ReadonlyArray
+}
+
+const matchesType = (pattern: string, eventType: string): boolean => {
+ if (pattern === eventType || pattern === "*") return true
+ if (pattern.endsWith(".*")) return eventType.startsWith(pattern.slice(0, -1))
+ return false
+}
+
+// §C2 default decomposition rules. Ordered; the FIRST matching rule wins. Autonomy levels follow §D:
+// analysis/diagnosis = level_1 (read-only), edits/tests/lint = level_2 (low-risk), review = level_1.
+export const DEFAULT_RULES: ReadonlyArray = [
+ {
+ match: "ci.failure",
+ steps: [
+ { capability: "code_edit", intent: "fix the failing build/tests", dependsOnIdx: [], requiredAutonomy: "level_2" },
+ { capability: "test_run", intent: "add/verify regression tests", dependsOnIdx: [0], requiredAutonomy: "level_2" },
+ ],
+ },
+ {
+ match: "pr.comment",
+ steps: [
+ { capability: "analyze", intent: "analyze the requested change", dependsOnIdx: [], requiredAutonomy: "level_1" },
+ { capability: "code_edit", intent: "implement the change", dependsOnIdx: [0], requiredAutonomy: "level_2" },
+ { capability: "review", intent: "review the change", dependsOnIdx: [1], requiredAutonomy: "level_1" },
+ ],
+ },
+ {
+ match: "monitor.alert",
+ steps: [
+ { capability: "diagnose", intent: "locate the root cause", dependsOnIdx: [], requiredAutonomy: "level_1" },
+ { capability: "code_edit", intent: "propose a fix", dependsOnIdx: [0], requiredAutonomy: "level_2" },
+ ],
+ },
+ {
+ // A push → read-only review pass (§A1 CodeReviewAgent covers `review`). level_1 (no edits). Without
+ // this rule git.push fell through to fallbackStep()'s "handle" capability, which no built-in declares
+ // → no_capable_agent. Emitting the concrete `review` capability lets CodeReviewAgent bind.
+ match: "git.push",
+ steps: [{ capability: "review", intent: "review the pushed changes", dependsOnIdx: [], requiredAutonomy: "level_1" }],
+ },
+ {
+ // A scheduled scan → read-only maintenance analysis (§A1 MaintenanceAgent covers `maintain`). level_1.
+ match: "schedule.scan",
+ steps: [
+ { capability: "maintain", intent: "run the scheduled maintenance scan", dependsOnIdx: [], requiredAutonomy: "level_1" },
+ ],
+ },
+ {
+ // An explicit repair request → same fix→test DAG as ci.failure (§A1 CodeFixAgent covers both caps).
+ match: "ci.repair.requested",
+ steps: [
+ { capability: "code_edit", intent: "apply the requested repair", dependsOnIdx: [], requiredAutonomy: "level_2" },
+ { capability: "test_run", intent: "verify the repair with tests", dependsOnIdx: [0], requiredAutonomy: "level_2" },
+ ],
+ },
+]
+
+// A single-subtask fallback for events with no matching rule: one generic handler subtask requiring the
+// conservative level_0 (context/suggest only), so an unknown event never silently escalates.
+const fallbackStep = (): RuleStep => ({
+ capability: "handle",
+ intent: "handle the event",
+ dependsOnIdx: [],
+ requiredAutonomy: "level_0",
+})
+
+// Derive the declared file scope from the event payload if it carries one. The bus payload is Unknown;
+// we defensively read a `files: string[]` field when present (producers of git/ci/pr events include it).
+export const scopeOf = (event: DeepAgentEvent.Event): ReadonlyArray => {
+ const payload = event.payload
+ if (payload && typeof payload === "object" && "files" in payload) {
+ const files = (payload as { files?: unknown }).files
+ if (Array.isArray(files) && files.every((f) => typeof f === "string")) return files as string[]
+ }
+ return []
+}
+
+/**
+ * §C2 — decompose an event into a subtask DAG. `rules` defaults to DEFAULT_RULES; pass a custom table
+ * to extend. The returned subtasks preserve rule order; `dependsOn` holds the concrete ids of the
+ * referenced earlier subtasks.
+ *
+ * IDs: by default each subtask gets a fresh ascending id (`idAt` injects the clock for tests). Pass
+ * `stableIDPrefix` to make ids DETERMINISTIC — `${prefix}:${stepIndex}` — so re-partitioning the SAME
+ * event (e.g. the dispatcher's retry pump re-driving a nacked delivery) yields identical subtask ids.
+ * The Multi-Agent Runtime uses `event.id` as the prefix so coordination-event idempotency keys are
+ * stable across retries (no duplicate agent.task.* / duplicate execution).
+ */
+export const partition = (
+ event: DeepAgentEvent.Event,
+ options?: { rules?: ReadonlyArray; idAt?: number; stableIDPrefix?: string },
+): Partition => {
+ const rules = options?.rules ?? DEFAULT_RULES
+ const rule = rules.find((r) => matchesType(r.match, event.type))
+ const steps = rule ? rule.steps : [fallbackStep()]
+ const fileScope = scopeOf(event)
+
+ // assign ids first so dependsOnIdx can resolve to concrete ids. A stable prefix makes them
+ // deterministic per (event, step) for idempotent re-dispatch.
+ const ids = steps.map((_, i) =>
+ options?.stableIDPrefix != null
+ ? `tsk_${options.stableIDPrefix}:${i}`
+ : newTaskID(options?.idAt != null ? options.idAt + i : undefined),
+ )
+ const subtasks: Subtask[] = steps.map((step, i) => ({
+ id: ids[i],
+ capability: step.capability,
+ intent: step.intent,
+ // VALIDATE deps: every dependency must reference a STRICTLY EARLIER step (0 <= idx < i). This makes
+ // the DAG acyclic + topologically sorted BY CONSTRUCTION — a custom rule (the documented extension
+ // path) can't smuggle in a forward ref (dangling id), an out-of-range idx, or a cycle that would
+ // deadlock the runtime's dependency scheduler. Throws on violation rather than emitting a broken plan.
+ dependsOn: step.dependsOnIdx.map((idx) => {
+ if (!Number.isInteger(idx) || idx < 0 || idx >= i) {
+ throw new Error(
+ `TaskPartitioner: rule for "${event.type}" step ${i} has invalid dependsOnIdx ${idx} (must be an earlier step index 0..${i - 1})`,
+ )
+ }
+ return ids[idx]
+ }),
+ fileScope,
+ requiredAutonomy: step.requiredAutonomy,
+ }))
+
+ return { event, subtasks }
+}
+
+// Which available agents can execute a subtask (declare its required capability). Returns them in
+// registry order; empty ⇒ the runtime must block the subtask (agent.task.blocked, no capable agent).
+export const capableAgents = (
+ subtask: Subtask,
+ agents: ReadonlyArray,
+): ReadonlyArray => agents.filter((a) => (a.capabilities ?? []).includes(subtask.capability))
diff --git a/packages/core/src/deepagent/workspace-concurrency.ts b/packages/core/src/deepagent/workspace-concurrency.ts
new file mode 100644
index 00000000..9c6f34b1
--- /dev/null
+++ b/packages/core/src/deepagent/workspace-concurrency.ts
@@ -0,0 +1,86 @@
+export * as WorkspaceConcurrency from "./workspace-concurrency"
+
+import { Context, Effect, Layer } from "effect"
+import { WorkspaceConfig } from "./workspace-config"
+import { RateLimiter } from "./rate-limiter"
+
+// V4.0 §E2 — the AGENT-EXECUTION CONCURRENCY gate. A per-workspace in-flight counter that caps how many
+// agent runs execute at once in a workspace (default AGENT_EXEC_CONCURRENT_PER_WORKSPACE = 5, overridable
+// via WorkspaceConfig.rateLimits.agentExecConcurrent). This is a CONCURRENCY cap, not a windowed rate —
+// distinct from the event-publish rate limiter — so it tracks a live count that goes up on `acquire` and
+// down on `release` (the caller MUST release when a run finishes, in a finalizer/ensuring).
+//
+// STATE: a plain in-memory Map. Reads (`depth`/`totalDepth`) are SYNCHRONOUS so a
+// dispatcher's queueDepth callback can sample the live count without an Effect round-trip. `acquire` is an
+// Effect because it consults WorkspaceConfig for the per-workspace cap; `release` is synchronous.
+//
+// LAYERING: `core`. Depends only on WorkspaceConfig. This is a reusable PRIMITIVE — it is NOT wired into
+// the multi-agent runtime or the event dispatcher here; that integration is owned by the main thread.
+
+export interface AcquireResult {
+ // whether this acquire was admitted (in-flight was below the cap) and the counter incremented. When
+ // false the counter is UNCHANGED — the caller must not run and must NOT call `release`.
+ readonly admitted: boolean
+ // the workspace's in-flight depth AFTER this acquire (== prior depth when not admitted).
+ readonly depth: number
+ // the resolved cap that was applied (config override or the AGENT_EXEC_CONCURRENT_PER_WORKSPACE default).
+ readonly cap: number
+}
+
+export interface Interface {
+ /**
+ * §E2 — try to admit one agent run in `workspaceID`. Resolves the cap from
+ * WorkspaceConfig.rateLimits.agentExecConcurrent (fallback AGENT_EXEC_CONCURRENT_PER_WORKSPACE); if the
+ * current in-flight depth is below the cap it increments and returns `admitted: true`, otherwise it
+ * leaves the counter untouched and returns `admitted: false`. The caller releases on run completion.
+ */
+ readonly acquire: (workspaceID: string) => Effect.Effect
+ /** Release one in-flight slot for `workspaceID` (floored at 0 — a double-release can't go negative). */
+ readonly release: (workspaceID: string) => void
+ /** Current in-flight depth for `workspaceID` (synchronous — safe for a dispatcher queueDepth callback). */
+ readonly depth: (workspaceID: string) => number
+ /** Total in-flight depth across all workspaces (synchronous). */
+ readonly totalDepth: () => number
+}
+
+export class Service extends Context.Service()("@deepagent-code/WorkspaceConcurrency") {}
+
+export const layer = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const config = yield* WorkspaceConfig.Service
+ // in-flight run count per workspace. A key is absent (== 0) until the first admitted acquire, and is
+ // deleted again when it drops back to 0 so idle workspaces don't accrue entries.
+ const inFlight = new Map()
+
+ const depth: Interface["depth"] = (workspaceID) => inFlight.get(workspaceID) ?? 0
+
+ const totalDepth: Interface["totalDepth"] = () => {
+ let total = 0
+ for (const n of inFlight.values()) total += n
+ return total
+ }
+
+ const acquire: Interface["acquire"] = (workspaceID) =>
+ Effect.gen(function* () {
+ const resolved = yield* config.get(workspaceID)
+ const cap = resolved.rateLimits.agentExecConcurrent ?? RateLimiter.AGENT_EXEC_CONCURRENT_PER_WORKSPACE
+ const current = inFlight.get(workspaceID) ?? 0
+ if (current >= cap) return { admitted: false, depth: current, cap }
+ const next = current + 1
+ inFlight.set(workspaceID, next)
+ return { admitted: true, depth: next, cap }
+ })
+
+ const release: Interface["release"] = (workspaceID) => {
+ const current = inFlight.get(workspaceID) ?? 0
+ const next = current - 1
+ if (next <= 0) inFlight.delete(workspaceID)
+ else inFlight.set(workspaceID, next)
+ }
+
+ return Service.of({ acquire, release, depth, totalDepth })
+ }),
+)
+
+export const defaultLayer = layer.pipe(Layer.provide(WorkspaceConfig.defaultLayer))
diff --git a/packages/core/src/deepagent/workspace-config-sql.ts b/packages/core/src/deepagent/workspace-config-sql.ts
new file mode 100644
index 00000000..59315417
--- /dev/null
+++ b/packages/core/src/deepagent/workspace-config-sql.ts
@@ -0,0 +1,21 @@
+import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
+
+// V4.0 — per-workspace configuration store. One row per workspace holding the V4 policy knobs that
+// four subsystems need but that must be tunable per tenant rather than baked into code:
+// §A3 retention — how many days of durable events/audit to keep before the retention sweep prunes.
+// §E4 quiet hours — the workspace's quiet-hours window (local start/end hour + tz offset) for the
+// agent-push digest gate.
+// §E2 rate limits — per-workspace overrides for the event-publish + agent-execution ceilings.
+// §E1 trust — the set of event sources this workspace trusts (security-gate layer 1).
+// Stored as a single JSON `config` blob (schema-versioned, forward-compatible) rather than a wide row,
+// so adding a knob is a schema-version bump, not a migration. Absent row ⇒ the code's lenient defaults.
+export const WorkspaceConfigTable = sqliteTable("deepagent_workspace_config", {
+ workspace_id: text().primaryKey(),
+ // JSON: WorkspaceConfig.Settings (see workspace-config.ts). Nullable columns are avoided — the whole
+ // config is one validated blob so partial writes can't leave an inconsistent row.
+ config: text({ mode: "json" }).$type().notNull(),
+ created_at: integer().notNull(),
+ updated_at: integer().notNull(),
+})
+
+export * as WorkspaceConfigSql from "./workspace-config-sql"
diff --git a/packages/core/src/deepagent/workspace-config.ts b/packages/core/src/deepagent/workspace-config.ts
new file mode 100644
index 00000000..66298172
--- /dev/null
+++ b/packages/core/src/deepagent/workspace-config.ts
@@ -0,0 +1,172 @@
+export * as WorkspaceConfig from "./workspace-config"
+
+import { Context, Effect, Layer, Schema } from "effect"
+import { eq } from "drizzle-orm"
+import { Database } from "../database/database"
+import { WorkspaceConfigTable } from "./workspace-config-sql"
+import { DeepAgentEvent } from "./deepagent-event"
+
+// V4.0 — the per-workspace config service. Reads/writes the single JSON `config` blob per workspace and
+// exposes it as a validated, defaulted `Settings`. Four V4 subsystems consume it:
+// §A3 retention sweep, §E4 quiet-hours digest gate, §E2 rate-limit ceilings, §E1 trusted-source gate.
+// DESIGN: an ABSENT row (or a partial blob) resolves to lenient DEFAULTS — so turning V4 on for an
+// existing workspace never changes behavior until an operator writes a config. The blob is
+// schema-versioned (`v`) for forward-compat.
+//
+// LAYERING: `core`. Pure durable state; the runtime + HTTP layer read/write it.
+
+// §E4 quiet-hours window (local hours + tz offset). start===end ⇒ no quiet window.
+// An hour-of-day: integer in [0,23]. Bounding this is load-bearing — an out-of-range endHour (e.g. 24)
+// makes the quiet window never exit (isWithinQuietHours compares against hour-of-day in [0,24)), so
+// low/normal pushes get deferred into the digest indefinitely. Reject the misconfig at decode time.
+const HourOfDay = Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 23 }))
+export const QuietHoursConfig = Schema.Struct({
+ startHour: HourOfDay, // 0-23 local
+ endHour: HourOfDay, // 0-23 local (wraps midnight if end < start)
+ tzOffsetMinutes: Schema.Int, // minutes east of UTC (e.g. +480 for UTC+8, -300 for UTC-5)
+})
+export type QuietHoursConfig = Schema.Schema.Type
+
+// §E2 per-workspace rate-limit overrides (omitted ⇒ the code defaults apply).
+export const RateLimitConfig = Schema.Struct({
+ eventPublishPerMinute: Schema.optional(Schema.Int),
+ agentPushPerHour: Schema.optional(Schema.Int),
+ agentExecConcurrent: Schema.optional(Schema.Int),
+})
+export type RateLimitConfig = Schema.Schema.Type
+
+// The full per-workspace settings. Every field OPTIONAL so a partial blob is valid; `resolve` fills
+// defaults. `v` is the blob schema version.
+export const Settings = Schema.Struct({
+ v: Schema.optional(Schema.Int),
+ // §A3 retention: days of durable events/audit to keep. Omitted ⇒ DEFAULT_RETENTION_DAYS.
+ retentionDays: Schema.optional(Schema.Int),
+ // §E4 quiet hours. Omitted ⇒ no quiet window (never quiet).
+ quietHours: Schema.optional(QuietHoursConfig),
+ // §E2 rate-limit overrides.
+ rateLimits: Schema.optional(RateLimitConfig),
+ // §E1 trusted event sources (security-gate layer 1). Omitted ⇒ DEFAULT_TRUSTED_SOURCES.
+ trustedSources: Schema.optional(Schema.Array(DeepAgentEvent.EventSource)),
+})
+export type Settings = Schema.Schema.Type
+
+// §A3 — default retention: 30 days (spec default), lenient.
+export const DEFAULT_RETENTION_DAYS = 30
+// §E1 — default trusted sources (layer 1 fail-closed posture). ONLY first-party sources are trusted by
+// default: "im" (authenticated in-product chat), "system" (the runtime's own coordination events), and
+// "schedule" (the internal scheduler). External webhook sources (git/ci/pr/monitor) are NOT trusted by
+// default — a workspace must EXPLICITLY opt them in by writing a per-workspace trustedSources list. This
+// makes L1 an opt-in trust boundary (safe by default) rather than opt-out (open by default): an
+// unconfigured workspace never auto-trusts an unauthenticated external webhook.
+export const DEFAULT_TRUSTED_SOURCES: ReadonlyArray = ["im", "system", "schedule"]
+
+// The fully-resolved (defaults-applied) view the subsystems consume.
+export interface Resolved {
+ readonly workspaceID: string
+ readonly retentionDays: number
+ readonly quietHours?: QuietHoursConfig
+ readonly rateLimits: {
+ readonly eventPublishPerMinute?: number
+ readonly agentPushPerHour?: number
+ readonly agentExecConcurrent?: number
+ }
+ readonly trustedSources: ReadonlyArray
+}
+
+const resolveSettings = (workspaceID: string, settings: Settings): Resolved => ({
+ workspaceID,
+ retentionDays:
+ settings.retentionDays != null && settings.retentionDays > 0 ? settings.retentionDays : DEFAULT_RETENTION_DAYS,
+ ...(settings.quietHours != null ? { quietHours: settings.quietHours } : {}),
+ rateLimits: {
+ ...(settings.rateLimits?.eventPublishPerMinute != null
+ ? { eventPublishPerMinute: settings.rateLimits.eventPublishPerMinute }
+ : {}),
+ ...(settings.rateLimits?.agentPushPerHour != null ? { agentPushPerHour: settings.rateLimits.agentPushPerHour } : {}),
+ ...(settings.rateLimits?.agentExecConcurrent != null
+ ? { agentExecConcurrent: settings.rateLimits.agentExecConcurrent }
+ : {}),
+ },
+ trustedSources:
+ settings.trustedSources != null && settings.trustedSources.length > 0
+ ? settings.trustedSources
+ : DEFAULT_TRUSTED_SOURCES,
+})
+
+export interface Interface {
+ /** Resolved settings (defaults applied) for a workspace. Never fails on a missing/partial row. */
+ readonly get: (workspaceID: string) => Effect.Effect
+ /** Merge a partial Settings patch into the workspace's config (upsert). Returns the resolved view. */
+ readonly set: (workspaceID: string, patch: Settings) => Effect.Effect
+}
+
+export class Service extends Context.Service()("@deepagent-code/WorkspaceConfig") {}
+
+export interface LayerOptions {
+ readonly now?: () => number
+}
+
+const decodeSettings = Schema.decodeUnknownSync(Settings)
+
+export const layerWith = (options?: LayerOptions) =>
+ Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const { db } = yield* Database.Service
+ const now = options?.now ?? Date.now
+
+ const readSettings = (workspaceID: string) =>
+ db
+ .select({ config: WorkspaceConfigTable.config })
+ .from(WorkspaceConfigTable)
+ .where(eq(WorkspaceConfigTable.workspace_id, workspaceID))
+ .get()
+ .pipe(
+ Effect.orDie,
+ Effect.map((row): Settings => {
+ if (!row) return {}
+ // a corrupt/legacy blob decodes to {} (lenient defaults) rather than crashing a reader.
+ try {
+ return decodeSettings(row.config)
+ } catch {
+ return {}
+ }
+ }),
+ )
+
+ const get: Interface["get"] = (workspaceID) =>
+ readSettings(workspaceID).pipe(Effect.map((s) => resolveSettings(workspaceID, s)))
+
+ const set: Interface["set"] = (workspaceID, patch) =>
+ Effect.gen(function* () {
+ const current = yield* readSettings(workspaceID)
+ // shallow-merge the patch over the current blob (nested objects replace wholesale — a caller
+ // sets the full quietHours/rateLimits object, matching a settings-form save).
+ const merged: Settings = {
+ v: 1,
+ ...current,
+ ...patch,
+ ...(patch.quietHours !== undefined ? { quietHours: patch.quietHours } : {}),
+ ...(patch.rateLimits !== undefined ? { rateLimits: patch.rateLimits } : {}),
+ ...(patch.trustedSources !== undefined ? { trustedSources: patch.trustedSources } : {}),
+ }
+ const at = now()
+ yield* db
+ .insert(WorkspaceConfigTable)
+ .values([{ workspace_id: workspaceID, config: merged, created_at: at, updated_at: at }])
+ .onConflictDoUpdate({
+ target: WorkspaceConfigTable.workspace_id,
+ set: { config: merged, updated_at: at },
+ })
+ .run()
+ .pipe(Effect.orDie)
+ return resolveSettings(workspaceID, merged)
+ })
+
+ return Service.of({ get, set })
+ }),
+ )
+
+export const layer = layerWith()
+
+export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
diff --git a/packages/core/src/im/agent-executor.ts b/packages/core/src/im/agent-executor.ts
index 70efa5d8..15118ca5 100644
--- a/packages/core/src/im/agent-executor.ts
+++ b/packages/core/src/im/agent-executor.ts
@@ -53,6 +53,10 @@ export const AgentExecutionResult = Schema.Struct({
}),
),
timeout: Schema.Boolean,
+ // V4.1 §S1.2: true when the message was absorbed as a mid-turn STEER into an already-running turn
+ // rather than executed as a fresh turn — the reply streams through that running turn's own path, so
+ // this result carries no synthesized `content`. Optional/additive: absent ⇒ a normal turn (unchanged).
+ steered: Schema.optional(Schema.Boolean),
})
export type AgentExecutionResult = Schema.Schema.Type
diff --git a/packages/core/src/im/agent-list-provider.ts b/packages/core/src/im/agent-list-provider.ts
index a955ffe7..b42af665 100644
--- a/packages/core/src/im/agent-list-provider.ts
+++ b/packages/core/src/im/agent-list-provider.ts
@@ -1,5 +1,6 @@
import { Context, Effect, Layer } from "effect"
import { AgentV2 } from "../agent"
+import { BUILTIN_AGENT_DESCRIPTORS } from "./builtin-agents"
import type { AgentDescriptor } from "./mention-parser"
/** Query context shared by every provider read. */
@@ -81,7 +82,14 @@ class AgentListProviderImpl implements AgentListProvider {
visible: true,
}))
- return descriptors
+ // V4.0 §A1 — APPEND the built-in production descriptors. AgentV2.Info carries
+ // no trigger/capability metadata, so the mapped `descriptors` above never match
+ // an autonomous event's trigger/capability query (every such event blocked with
+ // `no_capable_agent`). The built-ins carry that metadata and each `name` resolves
+ // to a REAL runnable agent (auto/general/plan), so both listAgents and the
+ // findByTrigger/findByCapability matchers below now see a matchable agent. They
+ // are `visible: false` → excluded from the human @mention UI, still matchable.
+ return [...descriptors, ...BUILTIN_AGENT_DESCRIPTORS]
})
}
diff --git a/packages/core/src/im/agent-orchestrator.ts b/packages/core/src/im/agent-orchestrator.ts
index 3c7d32c4..69b45f3e 100644
--- a/packages/core/src/im/agent-orchestrator.ts
+++ b/packages/core/src/im/agent-orchestrator.ts
@@ -59,7 +59,21 @@ export function executeAgentMentions(input: {
.listAgents({ workspaceID: input.workspaceID, userID: input.userID })
.pipe(Effect.catch(() => Effect.succeed([] as AgentDescriptorLike[])))
- const agentMap = new Map(availableAgents.map((a) => [a.name, a]))
+ // Build a name→agent map for the @mention path. Names are NOT unique across
+ // the list: ServerAgentListProvider appends BUILTIN_AGENT_DESCRIPTORS (all
+ // `visible:false`) that REUSE the primary names "auto"/"general" for the
+ // autonomous trigger/capability routers (matchable-but-hidden, see
+ // builtin-agents.ts). A naive `new Map(list.map(...))` is last-write-wins, so
+ // those hidden builtins would SHADOW the real visible config agents of the
+ // same name — and the `visible` filter below then drops @auto/@general to
+ // nothing. For a human @mention the visible agent must always win, so never
+ // let a non-visible entry overwrite an existing visible one of the same name.
+ const agentMap = new Map()
+ for (const agent of availableAgents) {
+ const existing = agentMap.get(agent.name)
+ if (existing && existing.visible && !agent.visible) continue
+ agentMap.set(agent.name, agent)
+ }
const agentsToExecute = input.mentionedAgentNames
.map((name) => agentMap.get(name))
.filter((a): a is AgentDescriptorLike => a !== undefined && a.visible)
@@ -207,12 +221,23 @@ function broadcastAgentResult(input: {
success: boolean
timeout: boolean
content?: string
+ // V4.1 §S1.2: the message was absorbed as a mid-turn STEER into an already-running turn (goal or a
+ // live chat turn). There is no reply of our own to post — the running turn replies through its own
+ // path — so this is an ACCEPTED outcome, NOT a failure. Broadcast a "steered" status and post nothing.
+ steered?: boolean
error?: { code: string; message: string; retryable: boolean }
}
broadcaster: IMBroadcaster
repo: IMRepositoryInterface
}): Effect.Effect {
return Effect.gen(function* () {
+ if (input.result.steered) {
+ input.broadcaster.broadcast(input.groupID, {
+ type: "agent_status",
+ data: { messageID: input.messageID, agentID: input.agentID, status: "steered" },
+ })
+ return
+ }
if (input.result.success && input.result.content) {
const agentMessage = yield* input.repo
.createMessage({
diff --git a/packages/core/src/im/attachment-storage.ts b/packages/core/src/im/attachment-storage.ts
new file mode 100644
index 00000000..5c99c53f
--- /dev/null
+++ b/packages/core/src/im/attachment-storage.ts
@@ -0,0 +1,114 @@
+import nodePath from "node:path"
+import { createHash } from "node:crypto"
+
+export * as AttachmentStorage from "./attachment-storage"
+
+/**
+ * §B3 文件上传 — pure policy + storage-path derivation for IM attachments.
+ *
+ * This module deliberately contains NO I/O and NO effect wiring: it is the security-critical core of the
+ * upload path (mime allow-list, size cap, sha256, and — most importantly — the server-derived storage
+ * path that makes path traversal impossible). Keeping it pure means it can be unit-tested directly and
+ * fast, decoupled from the multipart HTTP transport. The route handler calls these functions after the
+ * multipart parser has persisted the bytes to a temp file.
+ */
+
+// 50MB default cap, overridable via IM_MAX_ATTACHMENT_BYTES.
+export const maxAttachmentBytes = (): number => {
+ const parsed = parseInt(process.env.IM_MAX_ATTACHMENT_BYTES || "", 10)
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 50 * 1024 * 1024
+}
+
+// Generous but explicit mime allow-list. Extend via IM_ATTACHMENT_EXTRA_MIME (comma-separated).
+const BASE_ALLOWED_MIME = [
+ "image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml", "image/bmp", "image/tiff",
+ "application/pdf", "application/json", "application/zip", "application/gzip", "application/x-tar",
+ "application/octet-stream",
+ "application/msword",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ "application/vnd.ms-excel",
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ "text/plain", "text/markdown", "text/csv", "text/html", "text/xml",
+ "audio/mpeg", "audio/wav", "audio/ogg",
+ "video/mp4", "video/webm", "video/quicktime",
+]
+
+export const allowedMimeSet = (): Set =>
+ new Set([
+ ...BASE_ALLOWED_MIME,
+ ...String(process.env.IM_ATTACHMENT_EXTRA_MIME || "")
+ .split(",")
+ .map((s) => s.trim().toLowerCase())
+ .filter((s) => s.length > 0),
+ ])
+
+// Normalize a raw Content-Type header value to a bare lowercase mime (drop parameters like `; charset`).
+export const normalizeMime = (raw: string | undefined | null): string =>
+ (raw || "application/octet-stream").split(";")[0].trim().toLowerCase()
+
+// Any text/* subtype is permitted (source files, logs, etc.) in addition to the explicit allow-list.
+export const isAllowedMime = (mime: string, allow: Set = allowedMimeSet()): boolean =>
+ allow.has(mime) || mime.startsWith("text/")
+
+// Sanitize an arbitrary id into a single safe path segment: only [A-Za-z0-9._-], no `..`, bounded length.
+// This is what prevents a crafted workspace id from introducing a separator or traversal.
+export const sanitizeSegment = (s: string): string => {
+ const cleaned = s.replace(/[^A-Za-z0-9._-]/g, "_").replace(/\.{2,}/g, "_")
+ return cleaned.length > 0 ? cleaned.slice(0, 128) : "default"
+}
+
+export const sha256Hex = (bytes: Uint8Array): string => createHash("sha256").update(bytes).digest("hex")
+
+/**
+ * Derive the server-controlled storage path for an attachment: `/im-attachments//`.
+ *
+ * CRITICAL: the path is built ONLY from server-generated / server-resolved ids (never the client
+ * filename). The workspace segment is sanitized, and the result is verified to stay within the
+ * attachments base directory — so no client-supplied value can redirect where bytes land.
+ *
+ * Returns `{ ok: false }` if (defensively) the resolved path escapes the base directory.
+ */
+export const deriveStoragePath = (input: {
+ dataDir: string
+ workspaceID: string
+ attachmentID: string
+}):
+ | { readonly ok: true; readonly baseDir: string; readonly storagePath: string }
+ | { readonly ok: false; readonly error: "path_escape" } => {
+ const baseDir = nodePath.join(input.dataDir, "im-attachments", sanitizeSegment(input.workspaceID))
+ // The attachment id is also sanitized as belt-and-suspenders (it is server-generated `ima_…`, but we
+ // never want a single unexpected id to write outside the base).
+ const storagePath = nodePath.join(baseDir, sanitizeSegment(input.attachmentID))
+
+ const resolvedBase = nodePath.resolve(baseDir)
+ const resolvedTarget = nodePath.resolve(storagePath)
+ if (resolvedTarget !== resolvedBase && !resolvedTarget.startsWith(resolvedBase + nodePath.sep)) {
+ return { ok: false, error: "path_escape" }
+ }
+ return { ok: true, baseDir, storagePath }
+}
+
+export type ValidateResult =
+ | { readonly ok: true; readonly mime: string; readonly sizeBytes: number; readonly checksum: string }
+ | { readonly ok: false; readonly error: "unsupported_media_type"; readonly mime: string }
+ | { readonly ok: false; readonly error: "file_too_large"; readonly maxBytes: number }
+
+/**
+ * Validate an uploaded file's mime + size and compute its checksum. Pure over the already-read bytes.
+ */
+export const validateUpload = (input: {
+ contentType: string | undefined | null
+ bytes: Uint8Array
+ maxBytes?: number
+ allow?: Set
+}): ValidateResult => {
+ const mime = normalizeMime(input.contentType)
+ if (!isAllowedMime(mime, input.allow ?? allowedMimeSet())) {
+ return { ok: false, error: "unsupported_media_type", mime }
+ }
+ const maxBytes = input.maxBytes ?? maxAttachmentBytes()
+ if (input.bytes.byteLength > maxBytes) {
+ return { ok: false, error: "file_too_large", maxBytes }
+ }
+ return { ok: true, mime, sizeBytes: input.bytes.byteLength, checksum: sha256Hex(input.bytes) }
+}
diff --git a/packages/core/src/im/builtin-agents.ts b/packages/core/src/im/builtin-agents.ts
new file mode 100644
index 00000000..085a710a
--- /dev/null
+++ b/packages/core/src/im/builtin-agents.ts
@@ -0,0 +1,125 @@
+import type { AgentDescriptor } from "./mention-parser"
+
+/**
+ * V4.0 §A1 — BUILT-IN production agent descriptors that carry the trigger +
+ * capability metadata the autonomous event path needs.
+ *
+ * WHY THIS EXISTS: the core `AgentListProviderImpl` maps every AgentV2.Info to a
+ * descriptor WITHOUT any V3.8.1 metadata (AgentV2.Info has no triggers/capabilities
+ * — see agent-list-provider.ts). So `matchByTrigger`/`capableAgents` never matched
+ * ANY agent for an autonomous event (git.push / ci.failure / pr.comment /
+ * monitor.alert / schedule.scan / ci.repair.requested) → every such event blocked
+ * with `no_capable_agent` → the autonomous half never ran. These descriptors give
+ * the runtime something real to bind.
+ *
+ * HOW THEY RUN: each built-in's `name` is a REAL, resolvable agent the runner's
+ * `agents.get(descriptor.name)` will find:
+ * - "auto" — the renamed default primary agent (agent.ts defaultID; the Agent
+ * service also falls back to the legacy "build" name).
+ * - "general" — the general-purpose agent (agent/agent.ts registers `general`).
+ * - "plan" — the read-only plan agent (agent/agent.ts registers `plan`).
+ * The descriptor is METADATA only — it decides matchability + carries the
+ * autonomy/limits ceilings; the actual turn runs as the named agent.
+ *
+ * MATCHABLE-BUT-HIDDEN: `visible: false` keeps them out of the human @mention UI
+ * (agent-orchestrator filters on `visible`) while the pure matchers
+ * (matchByTrigger/matchByCapability/capableAgents — which ignore `visible`) still
+ * find them. These built-ins ONLY make an agent MATCHABLE; the §D autonomy gate and
+ * the §E security gate still apply on top, so autonomy stays conservative
+ * (diagnose/review = level_1 read-only; code_edit = level_2 low-risk).
+ *
+ * Capabilities cover every TaskPartitioner DEFAULT_RULES step so no autonomous
+ * event falls through to `no_capable_agent`:
+ * ci.failure → code_edit (level_2) + test_run (level_2)
+ * ci.repair.requested → code_edit (level_2) + test_run (level_2)
+ * pr.comment → analyze (level_1) + code_edit (level_2) + review (level_1)
+ * monitor.alert → diagnose (level_1) + code_edit (level_2)
+ * git.push → review (level_1)
+ * schedule.scan → maintain (level_1)
+ * Each of those capabilities is declared by at least one built-in below. (The
+ * partitioner's rules for git.push/schedule.scan/ci.repair.requested were added
+ * alongside these built-ins — an event with no rule falls back to the generic
+ * `handle` capability, which no built-in declares, so a matching rule is REQUIRED.)
+ */
+export const BUILTIN_AGENT_DESCRIPTORS: readonly AgentDescriptor[] = [
+ // 1. CodeFixAgent — the autonomous fixer. Triggered by CI failures + explicit
+ // repair requests; covers code_edit AND test_run so a ci.failure DAG's fix
+ // subtask binds here. level_2 (low-risk edits), file-scope + turn-time ceilings.
+ {
+ id: "builtin:codefix",
+ name: "auto",
+ displayName: "Code Fix Agent",
+ description: "Autonomously fixes failing builds/tests (ci.failure, ci.repair.requested).",
+ visible: false,
+ triggers: [{ event: "ci.failure" }, { event: "ci.repair.requested" }],
+ capabilities: ["code_edit", "test_run"],
+ autonomy: "level_2",
+ limits: { maxFilesChanged: 8, maxTurnDurationMs: 600_000 },
+ },
+ // 2. DiagnosisAgent — read-only root-cause locator for monitor alerts. level_1
+ // (post-hoc log only, no edits). Runs as the general-purpose agent.
+ {
+ id: "builtin:diagnosis",
+ name: "general",
+ displayName: "Diagnosis Agent",
+ description: "Read-only root-cause diagnosis for monitor alerts (monitor.alert).",
+ visible: false,
+ triggers: [{ event: "monitor.alert" }],
+ capabilities: ["diagnose"],
+ autonomy: "level_1",
+ limits: { maxTurnDurationMs: 300_000 },
+ },
+ // 3. CodeReviewAgent — read-only reviewer. Triggered by pushes + PR comments;
+ // covers review + analyze (the pr.comment DAG's analyze/review subtasks and
+ // the git.push reviewer). level_1 (read-only).
+ {
+ id: "builtin:codereview",
+ name: "general",
+ displayName: "Code Review Agent",
+ description: "Read-only review/analysis on pushes and PR comments (git.push, pr.comment).",
+ visible: false,
+ triggers: [{ event: "git.push" }, { event: "pr.comment" }],
+ capabilities: ["review", "analyze"],
+ autonomy: "level_1",
+ limits: {},
+ },
+ // 4. ChangeAgent — implements the requested change in a PR-comment pipeline. The
+ // pr.comment DAG's code_edit subtask binds here. level_2, file-scope ceiling.
+ {
+ id: "builtin:change",
+ name: "auto",
+ displayName: "Change Agent",
+ description: "Implements requested changes from PR comments (pr.comment).",
+ visible: false,
+ triggers: [{ event: "pr.comment" }],
+ capabilities: ["code_edit"],
+ autonomy: "level_2",
+ limits: { maxFilesChanged: 8 },
+ },
+ // 5. TestAgent — matched by CAPABILITY (test_run), not by trigger: the ci.failure
+ // DAG's test_run subtask can bind here (CodeFixAgent also covers test_run;
+ // registry order picks the first). No triggers → never trigger-matched.
+ {
+ id: "builtin:test",
+ name: "auto",
+ displayName: "Test Agent",
+ description: "Runs/adds regression tests for a fix DAG (capability: test_run).",
+ visible: false,
+ triggers: [],
+ capabilities: ["test_run"],
+ autonomy: "level_2",
+ limits: {},
+ },
+ // 6. MaintenanceAgent — scheduled maintenance scans. level_1 (read-only analysis).
+ {
+ id: "builtin:maintenance",
+ name: "general",
+ displayName: "Maintenance Agent",
+ description: "Scheduled maintenance scans (schedule.scan).",
+ visible: false,
+ triggers: [{ event: "schedule.scan" }],
+ capabilities: ["maintain", "analyze"],
+ autonomy: "level_1",
+ limits: {},
+ },
+]
diff --git a/packages/core/src/im/id.ts b/packages/core/src/im/id.ts
index 8694bc63..702ec237 100644
--- a/packages/core/src/im/id.ts
+++ b/packages/core/src/im/id.ts
@@ -25,3 +25,13 @@ export const MessageID = Schema.String.check(Schema.isStartsWith("imsg_")).pipe(
})),
)
export type MessageID = typeof MessageID.Type
+
+// V4.0 §B3/§B4 — IM file attachment id. Distinct prefix ("ima_") so an attachment id can never be
+// mistaken for a group/member/message id in a shared code path.
+export const AttachmentID = Schema.String.check(Schema.isStartsWith("ima_")).pipe(
+ Schema.brand("IM.Attachment.ID"),
+ withStatics((schema) => ({
+ create: () => schema.make("ima_" + Identifier.ascending()),
+ })),
+)
+export type AttachmentID = typeof AttachmentID.Type
diff --git a/packages/core/src/im/mention-parser.ts b/packages/core/src/im/mention-parser.ts
index 33a4aa0c..92e2546d 100644
--- a/packages/core/src/im/mention-parser.ts
+++ b/packages/core/src/im/mention-parser.ts
@@ -84,6 +84,8 @@ export const DEFAULT_AUTONOMY_LEVEL: AutonomyLevel = "level_0"
* maxConcurrency — unset ⇒ unlimited concurrent turns
* maxTokensPerTurn — unset ⇒ no per-turn token budget
* maxTurnDurationMs — unset ⇒ no per-turn time budget
+ * maxFilesChanged — unset ⇒ no per-subtask file-scope ceiling (§C1 max_files_changed)
+ * maxTokensPerHour — unset ⇒ no per-agent-per-hour LLM token budget (§E2 token budget)
* writablePaths — unset ⇒ no extra path restriction (kernel permissions apply)
* toolWhitelist — unset ⇒ all tools allowed (kernel permissions apply)
*/
@@ -91,6 +93,16 @@ export const AgentLimits = Schema.Struct({
maxConcurrency: Schema.optional(Schema.Int),
maxTokensPerTurn: Schema.optional(Schema.Int),
maxTurnDurationMs: Schema.optional(Schema.Int),
+ // §C1 max_files_changed — the maximum number of files a single subtask may write. A subtask whose
+ // declared fileScope exceeds this is BLOCKED (terminal) — the partition won't shrink on retry, so
+ // blocking (not deferring) is the honest outcome. Unset ⇒ no ceiling.
+ maxFilesChanged: Schema.optional(Schema.Int),
+ // §E2 max_tokens_per_hour — a per-agent-per-hour LLM token budget. Enforced where real token usage is
+ // available (the multi-agent runtime debits the runner's reported tokensUsed against a per-agent
+ // fixed-window counter; a subtask that would run with the agent already over budget is deferred).
+ // Unset ⇒ no budget. NOTE: event-driven turns currently report tokensUsed:0 (usage not yet threaded
+ // through the event turn runner), so this budget only bites for runners that DO report usage.
+ maxTokensPerHour: Schema.optional(Schema.Int),
// mutable arrays: this schema flows into the deep-merged Config.Info agent map (config.ts
// mergeDeep), whose target type has mutable arrays; a readonly decode would be unassignable.
// Mutable is a safe superset — assignable to any readonly consumer.
diff --git a/packages/core/src/im/push-log-sql.ts b/packages/core/src/im/push-log-sql.ts
new file mode 100644
index 00000000..9fb6c45c
--- /dev/null
+++ b/packages/core/src/im/push-log-sql.ts
@@ -0,0 +1,55 @@
+import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core"
+import * as IMID from "./id"
+
+// V4.0 §B4 — durable log of agent PROACTIVE pushes (one row per accepted/attempted push). Backs the
+// §B2 rate-limit accounting (per agent per group per window) and the Oversight trace of what an agent
+// pushed and why. Kept in its own table (not folded into im_messages) so the push audit — reason,
+// priority, policy decision, idempotency key — is queryable independently of the delivered message.
+export const AgentPushLogTable = sqliteTable(
+ "im_agent_push_logs",
+ {
+ id: text().primaryKey(),
+ workspace_id: text().notNull(),
+ group_id: text().$type().notNull(),
+ agent_id: text().notNull(),
+ // §B2 request fields.
+ reason: text().notNull(),
+ priority: text().notNull(),
+ // the policy outcome: delivered | digest | blocked:. Recorded for the audit even when the
+ // push was rejected (so a burst of blocked pushes is visible to Oversight).
+ decision: text().notNull(),
+ // §B2 去重: unique per push attempt. The UNIQUE index below makes this the storage-level dedup key
+ // (mirrors deepagent_event.idempotency_key) — a re-attempt with the same key is a no-op, not a
+ // second delivery.
+ idempotency_key: text().notNull(),
+ // the delivered message id when the push resulted in an im_messages row (null for digest/blocked).
+ message_id: text().$type(),
+ // §B2 静默时段: the SCRUBBED content, retained for `digest` outcomes so the (later) digest builder
+ // has a source to batch. Null for blocked pushes (nothing to deliver). Delivered pushes carry it
+ // too for the audit trail.
+ content: text(),
+ created_at: integer().notNull(),
+ // §E4 digest flush marker: a `decision='digest'` push is held during quiet hours (no message
+ // written). NULL ⇒ still held (awaiting the quiet-hours-end digest); set to the flush epoch ms once
+ // the DigestBuilder has batched + delivered it, so a flushed row is never re-delivered (idempotent).
+ digest_flushed_at: integer(),
+ },
+ (table) => [
+ // §B2 去重: storage-enforced one-delivery-per-key.
+ uniqueIndex("idx_im_agent_push_logs_idempotency").on(table.idempotency_key),
+ // §B2 rate-limit scan + Oversight timeline: this agent's recent pushes to a group, newest first.
+ // Mirrors docs §B4 idx_im_agent_push_logs_agent_time.
+ index("idx_im_agent_push_logs_agent_time").on(table.agent_id, table.group_id, table.created_at),
+ // per-workspace audit sweep.
+ index("idx_im_agent_push_logs_workspace").on(table.workspace_id, table.created_at),
+ // §E4 digest scan: unflushed held-digest rows per workspace (decision='digest' AND
+ // digest_flushed_at IS NULL) so the DigestBuilder finds pending digests without a full-table scan.
+ index("idx_im_agent_push_logs_digest_pending").on(
+ table.workspace_id,
+ table.decision,
+ table.digest_flushed_at,
+ ),
+ ],
+)
+
+export * as PushLogSql from "./push-log-sql"
diff --git a/packages/core/src/im/repository.ts b/packages/core/src/im/repository.ts
index 28f1a2be..596ef4e9 100644
--- a/packages/core/src/im/repository.ts
+++ b/packages/core/src/im/repository.ts
@@ -1,8 +1,8 @@
import { Context, Effect, Layer, Schema } from "effect"
-import { and, desc, eq, isNull, lt } from "drizzle-orm"
+import { and, asc, desc, eq, gt, isNull, like, lt, or, sql } from "drizzle-orm"
import { Database } from "../database/database"
import * as IMID from "./id"
-import { GroupTable, MemberTable, MessageTable, GroupType, MemberType, MemberRole, SenderType, MessageType, MessageMetadata } from "./sql"
+import { AttachmentTable, GroupTable, MemberTable, MessageTable, GroupType, MemberType, MemberRole, SenderType, MessageType, MessageMetadata } from "./sql"
// Repository errors
export class IMRepositoryError extends Schema.ErrorClass("IMRepositoryError")({
@@ -59,6 +59,94 @@ export const MessagePage = Schema.Struct({
})
export type MessagePage = typeof MessagePage.Type
+export const IMAttachment = Schema.Struct({
+ id: Schema.String,
+ workspaceID: Schema.String,
+ projectID: Schema.NullOr(Schema.String),
+ groupID: Schema.NullOr(Schema.String),
+ messageID: Schema.NullOr(Schema.String),
+ uploadedBy: Schema.String,
+ storagePath: Schema.String,
+ filename: Schema.String,
+ mime: Schema.String,
+ sizeBytes: Schema.Number,
+ checksum: Schema.String,
+ createdAt: Schema.Number,
+ deletedAt: Schema.NullOr(Schema.Number),
+})
+export type IMAttachment = typeof IMAttachment.Type
+
+// §B3 composite (created_at, id) keyset cursor for ASC-ordered scans (thread + search). Encoded as
+// `_` so the tie-break is stable when many rows share a millisecond. Parsing is total:
+// a malformed cursor yields `undefined` (start from the beginning) rather than throwing — matching the
+// defensive posture of listMessages' cursor parsing.
+export interface CompositeCursor {
+ readonly createdAt: number
+ readonly id: string
+}
+export const encodeCompositeCursor = (createdAt: number, id: string): string => `${createdAt}_${id}`
+export const parseCompositeCursor = (cursor: string | undefined): CompositeCursor | undefined => {
+ if (!cursor) return undefined
+ const sep = cursor.indexOf("_")
+ if (sep <= 0) return undefined
+ const createdAt = parseInt(cursor.slice(0, sep), 10)
+ const id = cursor.slice(sep + 1)
+ if (isNaN(createdAt) || createdAt < 0 || id.length === 0) return undefined
+ return { createdAt, id }
+}
+
+// Escape LIKE wildcards in the fallback search so a user query containing % or _ is matched literally.
+// Paired with an ESCAPE clause at the query site is ideal, but SQLite treats a backslash as an ordinary
+// char by default; drizzle's `like` has no ESCAPE hook, so we neutralize the metacharacters by
+// stripping them — acceptable for the degraded LIKE fallback (FTS5 is the primary path).
+const escapeLike = (q: string): string => q.replace(/[%_\\]/g, "")
+
+// Escape an arbitrary user query into a syntactically-safe FTS5 MATCH expression. The primary FTS path
+// previously fed the RAW query straight into `content MATCH ${query}` — but FTS5 has its own query
+// grammar (column filters like `foo:`, prefix `*`, boolean `AND`/`OR`/`NOT`/`NEAR`, parentheses, quoted
+// phrases). Arbitrary input such as `foo:`, an unbalanced `"`, `(`, or `*` raises a SQLite syntax error
+// that surfaces as a 500 rather than empty results. We neutralize the grammar by treating the query as
+// plain text: tokenize on whitespace and wrap EACH term in a double-quoted FTS5 string literal (internal
+// `"` doubled, per FTS5 phrase-escaping), then join with a space. Space-separated quoted phrases are an
+// implicit AND in FTS5, so this yields term-AND matching (every term must appear) — a reasonable search
+// semantics that treats every character literally and can never throw. A query that tokenizes to nothing
+// (empty or whitespace-only) would make MATCH throw on an empty expression, so we emit `""` (a quoted
+// empty phrase) which is syntactically valid and matches nothing.
+const toFtsMatchQuery = (q: string): string => {
+ const terms = q.trim().split(/\s+/).filter((t) => t.length > 0)
+ if (terms.length === 0) return `""`
+ return terms.map((t) => `"${t.replace(/"/g, '""')}"`).join(" ")
+}
+
+// Map an im_messages row (snake_case) to the camelCase IMMessage domain model.
+const mapMessageRow = (m: {
+ id: string
+ group_id: string
+ sender_id: string
+ sender_type: string
+ type: string
+ content: string
+ mentions: readonly string[] | null
+ metadata: unknown
+ reply_to_id: string | null
+ created_at: number
+ updated_at: number
+ deleted_at: number | null
+}): IMMessage => ({
+ id: m.id,
+ groupID: m.group_id,
+ senderID: m.sender_id,
+ senderType: m.sender_type,
+ type: m.type,
+ content: m.content,
+ mentions: m.mentions ?? null,
+ metadata: m.metadata ?? null,
+ replyToID: m.reply_to_id ?? null,
+ createdAt: m.created_at,
+ updatedAt: m.updated_at,
+ deletedAt: m.deleted_at ?? null,
+})
+
// Converged to the single canonical definition in `mention-parser.ts`
// (V3.8.1 §C.3 / conflict C6) so the new optional metadata fields
// (triggers/capabilities/autonomy/context_sources/approval_required/limits)
@@ -72,6 +160,65 @@ export interface CreateGroupInput {
type: GroupType
name: string
createdBy: string
+ // §B3 — optional initial members added alongside the creator (creator is always added as owner). Used
+ // by the direct-group path to seat the counterparty; when omitted the group starts with just the
+ // creator (V3.8 behavior, unchanged).
+ members?: ReadonlyArray<{ memberID: string; memberType: MemberType; role?: MemberRole }>
+}
+
+// §B3 私聊 — create (or return the existing) direct 1:1 group between exactly two participants. The pair
+// is canonicalized so the same two participants always map to one group regardless of argument order,
+// preventing duplicate direct groups.
+export interface CreateDirectGroupInput {
+ workspaceID: string
+ projectID?: string
+ createdBy: string
+ // The two participants of the direct group. Exactly one must be the creator; the other is the
+ // counterparty (a user or an agent). Enforced in the repository.
+ members: ReadonlyArray<{ memberID: string; memberType: MemberType }>
+ // Optional display name; when omitted a deterministic name is derived from the pair.
+ name?: string
+}
+
+export interface ListThreadInput {
+ groupID: string
+ // The parent message id whose replies (reply_to_id === replyToID) are listed.
+ replyToID: string
+ cursor?: string
+ limit: number
+}
+
+export interface SearchMessagesInput {
+ workspaceID: string
+ userID: string
+ query: string
+ groupID?: string
+ senderType?: SenderType
+ type?: MessageType
+ // §B3 metadata filter — a `metadata.type` discriminant to match via json_extract (e.g. "code_ref").
+ metadataType?: string
+ cursor?: string
+ limit: number
+}
+
+export interface CreateAttachmentInput {
+ workspaceID: string
+ projectID?: string
+ groupID?: string
+ messageID?: string
+ uploadedBy: string
+ storagePath: string
+ filename: string
+ mime: string
+ sizeBytes: number
+ checksum: string
+}
+
+export interface ListAttachmentsInput {
+ workspaceID: string
+ groupID?: string
+ messageID?: string
+ limit: number
}
export interface CreateMessageInput {
@@ -122,12 +269,22 @@ export interface AddMemberInput {
export interface IMRepositoryInterface {
readonly listGroups: (input: ListGroupsInput) => Effect.Effect