-
----
-
-DeepAgent Code is an AI coding agent built on persistent document memory. It keeps [opencode](https://github.com/sst/opencode)'s runtime foundations and adds a control plane so the agent behaves less like a one-shot chat and more like a teammate that remembers your project, sharpens vague asks, and goes deep on hard problems.
-
-The features below start from a real need — something a plain coding agent, opencode included, leaves on the table — and work down to the architecture we built to serve it.
-
-## What You Can Do
-
-### Bring your history over — switch tools without starting from zero
-
-**The need:** You've built up months of context in another agent — Codex or Claude Code — and switching tools normally means abandoning all of it: the conversations, the accumulated memory, the skills you tuned. That cost alone keeps people on tools they've outgrown.
-
-**What DeepAgent does:** One-click import of your existing history. Point it at a Codex or Claude Code installation and it hot-imports your chat sessions, memory, and skills straight into DeepAgent — reading each tool's on-disk format, normalizing it, and replaying it into the document graph so imported conversations behave like native ones. Secrets are redacted on the way in, imported projects stay isolated so nothing collides with your active work, and re-running an import converges instead of duplicating. Available from the Settings "Import history" panel, a History view in the sidebar, or the `import-history` CLI command. Migration is a few minutes, not a fresh start.
-
-### Keep one conversation going indefinitely
-
-**The need:** Long tasks overflow the context window. Most agents respond by truncating history or summarizing everything at a threshold — so mid-task the agent forgets a decision you made an hour ago, or the window fills with stale tool output and quality falls off a cliff.
-
-**What DeepAgent does:** Your conversation is treated as a continuously maintained work state, not a growing chat log. Before every turn, the agent rebuilds a working set — the task anchor, the most recent exchanges verbatim, active file references, and only the older facts that are relevant right now — while the full history is archived durably and stays queryable. The working set is held to a hard fraction of the model window, so there's always room for the model to actually think and respond. You just keep talking; the agent keeps focus.
-
-### Switch windows, fork freely, never lose memory
-
-**The need:** You want to try two approaches, or hand the work to a fresh conversation, without starting from amnesia and without polluting the original thread.
-
-**What DeepAgent does:** Fork any conversation from a chosen message. The fork opens carrying the parent's memory up to that point, shows a full-width "derived from" marker at the top of its transcript, and nests folder-style under its origin in the session tree (subagents and forks alike, up to three levels deep). Knowledge flows up a scope hierarchy — what one session learns can be promoted to the whole project, and cross-project preferences live at the user-global layer — so switching windows is a clean handoff, not a reset.
-
-### Ask roughly, and let the agent sharpen it
-
-**The need:** A half-formed prompt gets a half-useful answer, and you don't always know how to phrase what you want.
-
-**What DeepAgent does:** Two scenario modes on the composer. **Direct** sends your prompt as-is — you own the wording. **Intelligence** refines a rough ask into a sharper prompt, surfaces a draft plan and decision suggestions, and waits for your confirmation before it automates anything. You decide how much the agent shapes the request.
-
-### Choose how much the agent drives — and who writes the plan
-
-**The need:** Sometimes you want the agent to just take a request and run; sometimes you want to steer it with a plan you control; sometimes you've already written the plan and just want it executed faithfully.
-
-**What DeepAgent does:** Three collaboration modes on the composer, picked from a single selector. **Auto** — the agent sets the objective, designs and plans as needed, and executes to completion. **Loop** — you describe the goal, the agent writes a `goal+plan.md` you can edit, then a supervised loop drives it to completion (plan → execute → verify per tick, with hard budget/step ceilings and objective completion checks). **Design** — you author `goal+plan.md` yourself and the agent executes your plan faithfully without redefining the goal. Orthogonal to mode, a permission control offers three presets — **Read-only**, **Request approval** (default), **Full access** — so autonomy and approval are separate, explicit choices.
-
-### Get a second opinion before high-risk decisions
-
-**The need:** Some decisions — a breaking migration, a security-sensitive change, an architecture call — deserve more than one confident pass agreeing with itself.
-
-**What DeepAgent does:** Convene an **Expert Panel** from the composer. Differentiated expert lenses (correctness, security, performance, architecture, repro) review the same frozen question independently, debate anonymously, and a deterministic (non-LLM) arbiter aggregates a verdict — with minority opinions preserved and a fail-closed bias toward escalating to you when the panel can't safely agree.
-
-### Read and govern what the agent knows
-
-**The need:** Persistent memory is only trustworthy if you can see it and correct it.
-
-**What DeepAgent does:** A **Repo & Wiki** view projects the four graphs into human-readable pages — browse and full-text-search the agent's knowledge, follow docs↔code cross-links, and edit governable Knowledge/Memory pages through the same evidence-gate the agent uses (Documents and Code stay read-only). A separate governance view lists learned facts grouped by project and global scope, so you approve what becomes durable.
-
-### Go deep on genuinely hard problems
-
-**The need:** Complex work — an architecture decision, a tricky migration, a subtle bug — needs more than a single confident pass. It needs research, a second opinion, and someone actively trying to poke holes.
-
-**What DeepAgent does:** At higher work strengths the primary agent decomposes the task, fans it out to focused subagents that research modules in parallel, synthesizes their findings, and then runs independent reviewers whose job is to *break* the plan rather than agree with it. Fan-out is bounded by a configurable concurrency ceiling, and live subagents surface in a session side panel and inline in the transcript so you can watch and jump into any of them.
-
-### Chat with your team and your agents in one place
-
-**The need:** Coordinating with teammates and driving agents usually happens in two different tools.
-
-**What DeepAgent does:** A per-project group chat lives in the session side panel. @mention an agent as a chat member and it runs the full agent loop — query code, generate, fix — pulling project knowledge and recent messages for context, then replies inline with live progress streaming.
-
-## How It Works
-
-Each capability above is served by a control-plane primitive underneath. These are the parts a plain runtime doesn't have.
-
-**Four-graph unification** — Code, knowledge, project memory, and the document graph are unified into one typed, bidirectionally-linked store. When the agent pulls context, a change to a symbol surfaces the design decisions, past diagnoses, and knowledge actually linked to it — connected context, not four disconnected keyword searches.
-
-**Domain packs** — 140+ composable knowledge packages spanning languages, frameworks, platforms (cloud, Kubernetes, CI), hardware, and business/risk domains (security, privacy, compliance). Each pack bundles typed documents (strategies, methodologies, knowledge, skills, failure dossiers) with detectors that auto-activate the right packs for your task; conflicts resolve stricter-policy-wins, and the active set is version-locked so a run is reproducible. Core stays domain-neutral — expertise is data on disk, not hardcoded.
-
-**Tiered knowledge invocation** — A monotonic strength ladder (`general → high → xhigh → max → ultra`) gates how much control-plane machinery engages. `general` stays close to the plain runtime — fast and cheap. Higher rungs progressively unlock durable knowledge, project handoff summaries, heavier strategy/methodology tiers, and multi-agent orchestration. You pay for depth only when you dial it up.
-
-**Self-learning** — After work lands, the agent proposes candidate knowledge, facts, and methodologies. Promotion is evidence-gated (a test passed, a diagnostic cleared, a validation confirmed) and user-controllable — durable knowledge is carried over deliberately, not silently guessed. Session-stable conclusions consolidate into project memory over time, so the next session starts smarter about *your* codebase.
-
-## Installation
-
-```bash
-npm install -g deepagent-code
-```
-
-Then run:
-
-```bash
-deepagent-code
-# or use the alias:
-deepagent
-```
-
-## Quick Example
-
-Start the agent and give it a task:
-
-```bash
-deepagent-code "add rate limiting to /api/users endpoint"
-```
-
-The agent will:
-
-1. Use LSP to find the endpoint definition and understand its structure
-2. Check project memory for existing middleware patterns
-3. Activate the relevant domain packs (backend API, the project's language)
-4. Implement rate limiting following project conventions
-5. Run tests, capture diagnostics, and propose a candidate memory: "This project uses express-rate-limit middleware"
-
-On your next session, when you ask to add rate limiting elsewhere, the agent already knows the pattern.
-
-## Core Concepts
-
-**Document graph** — All persistent state lives in typed documents: `knowledge`, `strategy`, `methodology`, `skill`, `memory`, `design`, `worklog`, `diagnosis`, `eval`. Documents link to each other (supports/blocks/conflicts/validates), forming a graph you can traverse.
-
-**Scope layers** — `session-private` (current conversation), `project-shared` (all sessions in this project), `user-global` (cross-project preferences), `public-system` (built-in skills), `sealed` (audit-only, never enters context).
-
-**Context admission** — Retrieval hits pass through admission gates. Full tool output (raw LSP dumps, diagnostics, capability indexes) is written to evidence artifacts, ref-linked and tool-only; only summaries and `file:line` snippets enter the model context. Sensitive values (SSH hosts, tokens, internal paths) are suggested, never auto-expanded.
-
-**AI IDE microservice** — Query code by symbol name and intent (e.g. `code_intel({ symbol: "AgentGateway.open", intent: "overview" })`), not file:line coordinates. Get definitions, references, call chains, type hierarchies, and diagnostics in one call. Built on LSP with 38 language servers; degrades gracefully to grep/read when no server is configured.
-
-**Preset MCP catalog** — Curated MCP servers for Git platforms, file search, read-only databases, and browser automation. Risk tiers are derived at load time from the catalog template (not user config, so they can't be injected), and servers default to not-connected with write and external-fetch operations behind approval gates.
-
-## Architecture
-
-```
-┌─────────────────────────────────────────────────────────────┐
-│ Control Plane (DeepAgent additions) │
-│ • Four-graph unified store (code + knowledge + memory + doc)│
-│ • Continuously maintained working state (memory + compaction)│
-│ • Domain pack system (composable, auto-activating knowledge)│
-│ • Context assembly & admission gates │
-│ • Multi-agent orchestration & adversarial review │
-│ • Evidence-gated learning & work-strength ladder │
-└─────────────────────────────────────────────────────────────┘
- │
-┌─────────────────────────────────────────────────────────────┐
-│ Runtime Foundations (from opencode) │
-│ • Agent loop & tool execution │
-│ • Session, fork & provider management │
-│ • MCP client runtime │
-│ • Permission system │
-└─────────────────────────────────────────────────────────────┘
- │
-┌─────────────────────────────────────────────────────────────┐
-│ Intelligence Layers │
-│ • LSP microservice (38 language servers) │
-│ • Preset MCP servers (git/files/db/browser) │
-│ • Domain adapters (validation & diagnostics) │
-│ • Diagnostic & validation loops │
-└─────────────────────────────────────────────────────────────┘
-```
-
-DeepAgent's control plane operates at provider-turn boundaries: it selects context before each model call and writes evidence back into the document graph afterward. It does not replace opencode's runtime — it layers on top.
-
-## Documentation
-
-- [Architecture & Design](design/README.md) — Control plane, code intelligence, MCP security model
-- [Security Policy](SECURITY.md) — Vulnerability reporting, known limitations
-- [Privacy Policy](PRIVACY.md) — Data handling and storage
-- [Contributing](CONTRIBUTING.md) — Development setup and guidelines
-- [Changelog](CHANGELOG.md) — Release history
-
-## License & Attribution
-
-DeepAgent Code is licensed under **AGPL-3.0-or-later**. If you modify and run it as a network service, you must make your source code available to users.
-
-This project is derived from [opencode](https://github.com/sst/opencode) (MIT License). See [NOTICE](NOTICE) for the upstream license and attribution. No endorsement by opencode or its contributors is implied.
-
----
-
-
+
+---
+
+DeepAgent Code is an AI coding agent built on persistent document memory. It keeps [opencode](https://github.com/sst/opencode)'s runtime foundations and adds a control plane so the agent behaves less like a one-shot chat and more like a teammate that remembers your project, sharpens vague asks, and goes deep on hard problems.
+
+The features below start from a real need — something a plain coding agent, opencode included, leaves on the table — and work down to the architecture we built to serve it.
+
+## What You Can Do
+
+### Bring your history over — switch tools without starting from zero
+
+**The need:** You've built up months of context in another agent — Codex or Claude Code — and switching tools normally means abandoning all of it: the conversations, the accumulated memory, the skills you tuned. That cost alone keeps people on tools they've outgrown.
+
+**What DeepAgent does:** One-click import of your existing history. Point it at a Codex or Claude Code installation and it hot-imports your chat sessions, memory, and skills straight into DeepAgent — reading each tool's on-disk format, normalizing it, and replaying it into the document graph so imported conversations behave like native ones. Secrets are redacted on the way in, imported projects stay isolated so nothing collides with your active work, and re-running an import converges instead of duplicating. Available from the Settings "Import history" panel, a History view in the sidebar, or the `import-history` CLI command. Migration is a few minutes, not a fresh start.
+
+### Keep one conversation going indefinitely
+
+**The need:** Long tasks overflow the context window. Most agents respond by truncating history or summarizing everything at a threshold — so mid-task the agent forgets a decision you made an hour ago, or the window fills with stale tool output and quality falls off a cliff.
+
+**What DeepAgent does:** Your conversation is treated as a continuously maintained work state, not a growing chat log. Before every turn, the agent rebuilds a working set — the task anchor, the most recent exchanges verbatim, active file references, and only the older facts that are relevant right now — while the full history is archived durably and stays queryable. The working set is held to a hard fraction of the model window, so there's always room for the model to actually think and respond. You just keep talking; the agent keeps focus.
+
+### Switch windows, fork freely, never lose memory
+
+**The need:** You want to try two approaches, or hand the work to a fresh conversation, without starting from amnesia and without polluting the original thread.
+
+**What DeepAgent does:** Fork any conversation from a chosen message. The fork opens carrying the parent's memory up to that point, shows a full-width "derived from" marker at the top of its transcript, and nests folder-style under its origin in the session tree (subagents and forks alike, up to three levels deep). Knowledge flows up a scope hierarchy — what one session learns can be promoted to the whole project, and cross-project preferences live at the user-global layer — so switching windows is a clean handoff, not a reset.
+
+### Ask roughly, and let the agent sharpen it
+
+**The need:** A half-formed prompt gets a half-useful answer, and you don't always know how to phrase what you want.
+
+**What DeepAgent does:** Two scenario modes on the composer. **Direct** sends your prompt as-is — you own the wording. **Intelligence** refines a rough ask into a sharper prompt, surfaces a draft plan and decision suggestions, and waits for your confirmation before it automates anything. You decide how much the agent shapes the request.
+
+### Choose how much the agent drives — and who writes the plan
+
+**The need:** Sometimes you want the agent to just take a request and run; sometimes you want to steer it with a plan you control; sometimes you've already written the plan and just want it executed faithfully.
+
+**What DeepAgent does:** Three collaboration modes on the composer, picked from a single selector. **Auto** — the agent sets the objective, designs and plans as needed, and executes to completion. **Loop** — you describe the goal, the agent writes a `goal+plan.md` you can edit, then a supervised loop drives it to completion (plan → execute → verify per tick, with hard budget/step ceilings and objective completion checks). **Design** — you author `goal+plan.md` yourself and the agent executes your plan faithfully without redefining the goal. Orthogonal to mode, a permission control offers three presets — **Read-only**, **Request approval** (default), **Full access** — so autonomy and approval are separate, explicit choices.
+
+### Get a second opinion before high-risk decisions
+
+**The need:** Some decisions — a breaking migration, a security-sensitive change, an architecture call — deserve more than one confident pass agreeing with itself.
+
+**What DeepAgent does:** Convene an **Expert Panel** from the composer. Differentiated expert lenses (correctness, security, performance, architecture, repro) review the same frozen question independently, debate anonymously, and a deterministic (non-LLM) arbiter aggregates a verdict — with minority opinions preserved and a fail-closed bias toward escalating to you when the panel can't safely agree.
+
+### Read and govern what the agent knows
+
+**The need:** Persistent memory is only trustworthy if you can see it and correct it.
+
+**What DeepAgent does:** A **Repo & Wiki** view projects the four graphs into human-readable pages — browse and full-text-search the agent's knowledge, follow docs↔code cross-links, and edit governable Knowledge/Memory pages through the same evidence-gate the agent uses (Documents and Code stay read-only). A separate governance view lists learned facts grouped by project and global scope, so you approve what becomes durable.
+
+### Go deep on genuinely hard problems
+
+**The need:** Complex work — an architecture decision, a tricky migration, a subtle bug — needs more than a single confident pass. It needs research, a second opinion, and someone actively trying to poke holes.
+
+**What DeepAgent does:** At higher work strengths the primary agent decomposes the task, fans it out to focused subagents that research modules in parallel, synthesizes their findings, and then runs independent reviewers whose job is to *break* the plan rather than agree with it. Fan-out is bounded by a configurable concurrency ceiling, and live subagents surface in a session side panel and inline in the transcript so you can watch and jump into any of them.
+
+### Chat with your team and your agents in one place
+
+**The need:** Coordinating with teammates and driving agents usually happens in two different tools.
+
+**What DeepAgent does:** A per-project group chat lives in the session side panel. @mention an agent as a chat member and it runs the full agent loop — query code, generate, fix — pulling project knowledge and recent messages for context, then replies inline with live progress streaming.
+
+## How It Works
+
+Each capability above is served by a control-plane primitive underneath. These are the parts a plain runtime doesn't have.
+
+**Four-graph unification** — Code, knowledge, project memory, and the document graph are unified into one typed, bidirectionally-linked store. When the agent pulls context, a change to a symbol surfaces the design decisions, past diagnoses, and knowledge actually linked to it — connected context, not four disconnected keyword searches.
+
+**Domain packs** — 140+ composable knowledge packages spanning languages, frameworks, platforms (cloud, Kubernetes, CI), hardware, and business/risk domains (security, privacy, compliance). Each pack bundles typed documents (strategies, methodologies, knowledge, skills, failure dossiers) with detectors that auto-activate the right packs for your task; conflicts resolve stricter-policy-wins, and the active set is version-locked so a run is reproducible. Core stays domain-neutral — expertise is data on disk, not hardcoded.
+
+**Tiered knowledge invocation** — A monotonic strength ladder (`general → high → xhigh → max → ultra`) gates how much control-plane machinery engages. `general` stays close to the plain runtime — fast and cheap. Higher rungs progressively unlock durable knowledge, project handoff summaries, heavier strategy/methodology tiers, and multi-agent orchestration. You pay for depth only when you dial it up.
+
+**Self-learning** — After work lands, the agent proposes candidate knowledge, facts, and methodologies. Promotion is evidence-gated (a test passed, a diagnostic cleared, a validation confirmed) and user-controllable — durable knowledge is carried over deliberately, not silently guessed. Session-stable conclusions consolidate into project memory over time, so the next session starts smarter about *your* codebase.
+
+## Installation
+
+```bash
+npm install -g deepagent-code
+```
+
+Then run:
+
+```bash
+deepagent-code
+# or use the alias:
+deepagent
+```
+
+## Quick Example
+
+Start the agent and give it a task:
+
+```bash
+deepagent-code "add rate limiting to /api/users endpoint"
+```
+
+The agent will:
+
+1. Use LSP to find the endpoint definition and understand its structure
+2. Check project memory for existing middleware patterns
+3. Activate the relevant domain packs (backend API, the project's language)
+4. Implement rate limiting following project conventions
+5. Run tests, capture diagnostics, and propose a candidate memory: "This project uses express-rate-limit middleware"
+
+On your next session, when you ask to add rate limiting elsewhere, the agent already knows the pattern.
+
+## Core Concepts
+
+**Document graph** — All persistent state lives in typed documents: `knowledge`, `strategy`, `methodology`, `skill`, `memory`, `design`, `worklog`, `diagnosis`, `eval`. Documents link to each other (supports/blocks/conflicts/validates), forming a graph you can traverse.
+
+**Scope layers** — `session-private` (current conversation), `project-shared` (all sessions in this project), `user-global` (cross-project preferences), `public-system` (built-in skills), `sealed` (audit-only, never enters context).
+
+**Context admission** — Retrieval hits pass through admission gates. Full tool output (raw LSP dumps, diagnostics, capability indexes) is written to evidence artifacts, ref-linked and tool-only; only summaries and `file:line` snippets enter the model context. Sensitive values (SSH hosts, tokens, internal paths) are suggested, never auto-expanded.
+
+**AI IDE microservice** — Query code by symbol name and intent (e.g. `code_intel({ symbol: "AgentGateway.open", intent: "overview" })`), not file:line coordinates. Get definitions, references, call chains, type hierarchies, and diagnostics in one call. Built on LSP with 38 language servers; degrades gracefully to grep/read when no server is configured.
+
+**Preset MCP catalog** — Curated MCP servers for Git platforms, file search, read-only databases, and browser automation. Risk tiers are derived at load time from the catalog template (not user config, so they can't be injected), and servers default to not-connected with write and external-fetch operations behind approval gates.
+
+## Architecture
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ Control Plane (DeepAgent additions) │
+│ • Four-graph unified store (code + knowledge + memory + doc)│
+│ • Continuously maintained working state (memory + compaction)│
+│ • Domain pack system (composable, auto-activating knowledge)│
+│ • Context assembly & admission gates │
+│ • Multi-agent orchestration & adversarial review │
+│ • Evidence-gated learning & work-strength ladder │
+└─────────────────────────────────────────────────────────────┘
+ │
+┌─────────────────────────────────────────────────────────────┐
+│ Runtime Foundations (from opencode) │
+│ • Agent loop & tool execution │
+│ • Session, fork & provider management │
+│ • MCP client runtime │
+│ • Permission system │
+└─────────────────────────────────────────────────────────────┘
+ │
+┌─────────────────────────────────────────────────────────────┐
+│ Intelligence Layers │
+│ • LSP microservice (38 language servers) │
+│ • Preset MCP servers (git/files/db/browser) │
+│ • Domain adapters (validation & diagnostics) │
+│ • Diagnostic & validation loops │
+└─────────────────────────────────────────────────────────────┘
+```
+
+DeepAgent's control plane operates at provider-turn boundaries: it selects context before each model call and writes evidence back into the document graph afterward. It does not replace opencode's runtime — it layers on top.
+
+## Documentation
+
+- [Architecture & Design](design/README.md) — Control plane, code intelligence, MCP security model
+- [Security Policy](SECURITY.md) — Vulnerability reporting, known limitations
+- [Privacy Policy](PRIVACY.md) — Data handling and storage
+- [Contributing](CONTRIBUTING.md) — Development setup and guidelines
+- [Changelog](CHANGELOG.md) — Release history
+
+## License & Attribution
+
+DeepAgent Code is licensed under **AGPL-3.0-or-later**. If you modify and run it as a network service, you must make your source code available to users.
+
+This project is derived from [opencode](https://github.com/sst/opencode) (MIT License). See [NOTICE](NOTICE) for the upstream license and attribution. No endorsement by opencode or its contributors is implied.
+
+---
+
+
AI coding agent with persistent memory and control plane
+
AI coding agent with persistent memory, autonomous goals, and a control plane
English |
@@ -42,11 +42,11 @@ The features below start from a real need — something a plain coding agent, op
**What DeepAgent does:** Fork any conversation from a chosen message. The fork opens carrying the parent's memory up to that point, shows a full-width "derived from" marker at the top of its transcript, and nests folder-style under its origin in the session tree (subagents and forks alike, up to three levels deep). Knowledge flows up a scope hierarchy — what one session learns can be promoted to the whole project, and cross-project preferences live at the user-global layer — so switching windows is a clean handoff, not a reset.
-### Ask roughly, and let the agent sharpen it
+### Pick how much autonomy you want, per task
-**The need:** A half-formed prompt gets a half-useful answer, and you don't always know how to phrase what you want.
+**The need:** Some asks want a fast answer in your exact words; others want the agent to plan first, or to run on its own until the job is done. One fixed behavior can't serve all three.
-**What DeepAgent does:** Two scenario modes on the composer. **Direct** sends your prompt as-is — you own the wording. **Intelligence** refines a rough ask into a sharper prompt, surfaces a draft plan and decision suggestions, and waits for your confirmation before it automates anything. You decide how much the agent shapes the request.
+**What DeepAgent does:** Three modes on the composer. **Auto** decides how to plan and act on your request. **Design** explores the problem and proposes a design before building anything. **Loop** turns a request into a supervised goal the agent works toward autonomously — iterating plan → execute → verify until the criteria are met — while you stay in control. You choose the autonomy level per task, not once for the whole tool.
### Choose how much the agent drives — and who writes the plan
@@ -72,6 +72,18 @@ The features below start from a real need — something a plain coding agent, op
**What DeepAgent does:** At higher work strengths the primary agent decomposes the task, fans it out to focused subagents that research modules in parallel, synthesizes their findings, and then runs independent reviewers whose job is to *break* the plan rather than agree with it. Fan-out is bounded by a configurable concurrency ceiling, and live subagents surface in a session side panel and inline in the transcript so you can watch and jump into any of them.
+### Set a goal and let it run — supervised, not unsupervised
+
+**The need:** Some work is a long haul — a migration, a green-the-suite push, a multi-step feature. You want to hand it off and walk away, but "walk away" can't mean "lose control."
+
+**What DeepAgent does:** Loop mode drives an autonomous goal loop against an objectively decidable finish line (tests pass, no diagnostics, reviewer clean, plan complete). It runs plan → execute → verify → iterate in the background, with hard ceilings on ticks, tokens, wall-clock, and cost so it can never run away. A status bar shows live progress; you can hot-edit the plan mid-run, pause and resume from exactly where it stopped, or take over — a takeover pauses autonomy escalation and hands control back to you. A goal with steps that need a human never reports "done"; it routes to you. Autonomy is a dial you hold, not a switch you flip and hope.
+
+### Get a second opinion that actually argues
+
+**The need:** For a high-stakes decision, one confident answer isn't enough — you want independent experts who see the same evidence, disagree, and defend their positions.
+
+**What DeepAgent does:** Convene an Expert Panel on the current conversation from the composer. Pick single-round for a quick multi-lens review, or multi-round for a real debate: panelists (correctness, security, design, …) each render a verdict, then see each other's *anonymized* opinions and revise across up to three rounds — identity stripped so nobody anchors on "the security expert said." An arbiter synthesizes the surviving verdict. Fan-out and rounds are bounded, and every opinion (including the losers') is archived. The panel convenes on demand, or a running goal loop can convene it at high-risk decision points.
+
### Chat with your team and your agents in one place
**The need:** Coordinating with teammates and driving agents usually happens in two different tools.
@@ -90,6 +102,8 @@ Each capability above is served by a control-plane primitive underneath. These a
**Self-learning** — After work lands, the agent proposes candidate knowledge, facts, and methodologies. Promotion is evidence-gated (a test passed, a diagnostic cleared, a validation confirmed) and user-controllable — durable knowledge is carried over deliberately, not silently guessed. Session-stable conclusions consolidate into project memory over time, so the next session starts smarter about *your* codebase.
+**Supervised autonomy** — The goal loop, expert panel, and multi-agent fan-out run on an event-driven substrate with the guardrails autonomy needs: hard budget ceilings (ticks/tokens/wall-clock/cost), a stall detector that stops rather than spins, layered permission and safety gates that fail closed, human takeover that pauses escalation, and a full audit trail written back to the document graph. An Agent Dashboard surfaces task success rate, conflicts, and dead-letter events. Autonomy is bounded and observable by construction — never a black box you can't stop.
+
## Installation
```bash
@@ -144,6 +158,7 @@ On your next session, when you ask to add rate limiting elsewhere, the agent alr
│ • Domain pack system (composable, auto-activating knowledge)│
│ • Context assembly & admission gates │
│ • Multi-agent orchestration & adversarial review │
+│ • Supervised goal loop & expert panel (event-driven) │
│ • Evidence-gated learning & work-strength ladder │
└─────────────────────────────────────────────────────────────┘
│
diff --git a/README.zh.md b/README.zh.md
index 91cd323c..e59e34c5 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -6,7 +6,7 @@
-
具备持久记忆与控制平面的 AI 编程智能体
+
具备持久记忆、自主目标与控制平面的 AI 编程智能体
English |
@@ -42,11 +42,11 @@ DeepAgent Code 是一个构建在持久文档记忆之上的 AI 编程智能体
**DeepAgent 的做法:** 从任意一条消息分叉当前对话。分叉出的新对话会继承父对话到该点为止的记忆,在时间线顶部显示一条贯穿窗口的"从对话派生"分割线,并像文件夹一样嵌套挂在来源对话之下(子 agent 与分叉同理,最多三层深)。知识沿作用域层级向上流动——单个会话学到的东西可提升到整个项目,跨项目的偏好则沉淀在用户全局层——所以换窗口是一次干净的交接,而非一次清零重来。
-### 想到哪问到哪,让智能体替你打磨
+### 自主程度,按任务自己挑
-**需求:** 半成品的提问只能换来半有用的回答,而你未必总知道该怎么把想要的东西说清楚。
+**需求:** 有些提问想要一个快速、原话作答;有些希望智能体先规划;还有些希望它自己跑到把活干完为止。一种固定行为伺候不了这三种。
-**DeepAgent 的做法:** 输入框上有两种情景模式。**直接**模式原样发送你的提示——措辞由你做主。**智能**模式会把粗糙的想法打磨成更精准的提示,给出草拟的方案和决策建议,并在自动执行任何操作前等你确认。智能体替你塑形到什么程度,由你决定。
+**DeepAgent 的做法:** 输入框上有三种模式。**自动(Auto)** 由智能体决定如何规划并执行你的请求。**设计(Design)** 先探索问题、给出设计方案,再动手构建。**循环(Loop)** 把请求变成一个受监督的目标,智能体朝它自主推进——不断地"规划 → 执行 → 校验"直到满足完成判据——而掌控权始终在你手里。自主程度按任务来选,而不是为整个工具一次性定死。
### 选择智能体驱动的方式——以及由谁来写计划
@@ -72,6 +72,18 @@ DeepAgent Code 是一个构建在持久文档记忆之上的 AI 编程智能体
**DeepAgent 的做法:** 在更高的工作强度下,主智能体会拆解任务,扇出给专注的子 agent 并行调研各个模块,综合它们的发现,再运行独立的审阅者——审阅者的职责是"击破"方案,而不是附和。扇出受可配置的并发上限约束;运行中的子 agent 会出现在会话侧栏面板和时间线内联卡片里,你可以旁观并随时跳进任意一个。
+### 定个目标让它自己跑——是受监督,而非放养
+
+**需求:** 有些工作是场持久战——一次迁移、一轮把测试全刷绿、一个多步骤的特性。你想把它交出去然后走开,但"走开"不能等于"失控"。
+
+**DeepAgent 的做法:** 循环模式驱动一个自主目标回路,朝着一条可客观判定的终点线推进(测试通过、无诊断、审阅无异议、计划完成)。它在后台跑"规划 → 执行 → 校验 → 迭代",并对轮次、令牌、墙钟时间和成本设有硬上限,绝不会失控狂奔。状态条实时显示进度;你可以在运行中热编辑计划、暂停后从中断处精确恢复,或直接接管——接管会暂停自主升级、把控制权交还给你。一个包含需要人工处理步骤的目标绝不会谎报"完成",而是转交给你。自主是你握在手里的旋钮,不是拨一下就听天由命的开关。
+
+### 要一个真会争论的第二意见
+
+**需求:** 面对高风险决策,一个自信的答案不够——你想要一批独立专家,看着同样的证据,各持己见、各自辩护。
+
+**DeepAgent 的做法:** 从输入框就当前对话召集专家团。选单轮做一次快速的多视角评审,或选多轮进行真正的辩论:各位专家(正确性、安全、设计……)先各自给出裁定,然后看到彼此**匿名化**的意见并在最多三轮里修正——身份被抹去,谁都无法因"是安全专家说的"而锚定。一位仲裁者综合出最终存活的裁定。扇出与轮数都有界,每一条意见(包括落败的)都会被归档。专家团可按需召集,运行中的目标回路也能在高风险决策点上召集它。
+
### 团队与智能体,在同一处协作
**需求:** 和队友协调、驱动智能体,通常发生在两个不同的工具里。
@@ -90,6 +102,8 @@ DeepAgent Code 是一个构建在持久文档记忆之上的 AI 编程智能体
**自学习** — 工作落地后,智能体会提出候选的知识、事实与方法论。晋升是证据门控的(一项测试通过、一条诊断清零、一次校验确认)且由用户掌控——持久知识是被有意结转的,而非后台悄悄猜出来的。会话中稳定的结论会随时间巩固进项目记忆,于是下一次会话对*你的*代码库上手更聪明。
+**受监督的自主** — 目标回路、专家团与多智能体扇出都跑在一套事件驱动的底座上,并带着自主所必需的护栏:硬性预算上限(轮次/令牌/墙钟/成本)、宁停不空转的停滞检测、层层失败即关闭的权限与安全门、暂停升级的人工接管,以及写回文档图的完整审计轨迹。一个 Agent 面板会呈现任务成功率、冲突与死信事件。自主在构造上就是有界且可观测的——绝不是一个你停不下来的黑盒。
+
## 安装
```bash
@@ -144,6 +158,7 @@ deepagent-code "为 /api/users 端点添加限流"
│ • 领域包系统(可组合、自动激活的知识) │
│ • 上下文装配与准入门 │
│ • 多智能体编排与对抗式审阅 │
+│ • 受监督的目标回路与专家团(事件驱动) │
│ • 证据门控的学习 + 工作强度阶梯 │
└─────────────────────────────────────────────────────────────┘
│
diff --git a/bun.lock b/bun.lock
index 63a61400..82ed0bd7 100644
--- a/bun.lock
+++ b/bun.lock
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@deepagent-code/app",
- "version": "1.0.0-beta",
+ "version": "1.4.0",
"dependencies": {
"@codemirror/autocomplete": "6",
"@codemirror/commands": "6",
@@ -344,7 +344,7 @@
},
"packages/desktop": {
"name": "@deepagent-code/desktop",
- "version": "1.2.0",
+ "version": "1.4.0",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"effect": "catalog:",
diff --git a/packages/app/package.json b/packages/app/package.json
index 888f9b32..9435d21f 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -1,6 +1,6 @@
{
"name": "@deepagent-code/app",
- "version": "1.0.0-beta",
+ "version": "1.4.0",
"description": "",
"type": "module",
"exports": {
diff --git a/packages/app/src/components/deepagent/goal-plan-edit-dialog.tsx b/packages/app/src/components/deepagent/goal-plan-edit-dialog.tsx
new file mode 100644
index 00000000..a7d3a00d
--- /dev/null
+++ b/packages/app/src/components/deepagent/goal-plan-edit-dialog.tsx
@@ -0,0 +1,164 @@
+import { For, Index, Show, createSignal } from "solid-js"
+import { Dialog, DialogFooter } from "@deepagent-code/ui/v2/dialog-v2"
+import { Button } from "@deepagent-code/ui/button"
+import { Icon } from "@deepagent-code/ui/icon"
+import { useLanguage } from "@/context/language"
+import { showToast } from "@/utils/toast"
+import { editPlanGoal, type GoalPlanInput, type PanelGoalClient } from "./panel-goal.api"
+import type { SessionPlan } from "@/context/global-sync/types"
+
+/**
+ * V4.1 §S2 — the goal plan HOT-EDIT dialog. Opened from the goal status bar for a running/paused goal.
+ * Pre-fills from the live session_plan (the same plan the status bar reads), lets the user revise the
+ * goal text + step titles/statuses (add/remove steps), and POSTs the revision via editPlanGoal. The
+ * backend applies it between ticks (durable-doc upsert + stall re-baseline) — see goal-loop.applyPlanEdit.
+ *
+ * step_id is preserved for existing steps (so the backend reconciles ids + runtime evidence via
+ * buildPlanFromInput); a NEW step is sent without an id and the backend assigns one. Evidence is
+ * runtime-owned and never sent from the client.
+ */
+
+// The status values a user can pick — mirrors PlanStepStatus. `active` is included so a user can point
+// the goal at a specific step; the backend re-derives active_step_id from the first active step.
+const STATUS_OPTIONS = ["pending", "active", "done", "blocked", "cancelled"] as const
+type StatusOption = (typeof STATUS_OPTIONS)[number]
+
+const STATUS_LABEL_KEY: Record = {
+ pending: "composer.goal.editPlan.status.pending",
+ active: "composer.goal.editPlan.status.active",
+ done: "composer.goal.editPlan.status.done",
+ blocked: "composer.goal.editPlan.status.blocked",
+ cancelled: "composer.goal.editPlan.status.cancelled",
+}
+
+// A local editable step row. `step_id` is undefined for a freshly-added step (the backend assigns one).
+type EditStep = {
+ step_id?: string
+ title: string
+ status: StatusOption
+}
+
+const normStatus = (s: string): StatusOption =>
+ (STATUS_OPTIONS as readonly string[]).includes(s) ? (s as StatusOption) : "pending"
+
+export function GoalPlanEditDialog(props: {
+ sessionID: string
+ plan: SessionPlan | undefined
+ client: PanelGoalClient
+ onClose: () => void
+}) {
+ const language = useLanguage()
+ const [goal, setGoal] = createSignal(props.plan?.goal ?? "")
+ const [steps, setSteps] = createSignal(
+ (props.plan?.steps ?? []).map((s) => ({ step_id: s.step_id, title: s.title, status: normStatus(s.status) })),
+ )
+ const [busy, setBusy] = createSignal(false)
+
+ const setStep = (i: number, patch: Partial) =>
+ setSteps((prev) => prev.map((s, idx) => (idx === i ? { ...s, ...patch } : s)))
+ const addStep = () => setSteps((prev) => [...prev, { title: "", status: "pending" }])
+ const removeStep = (i: number) => setSteps((prev) => prev.filter((_, idx) => idx !== i))
+
+ // Valid to submit: a non-empty goal and at least one step whose title is non-empty. Empty-title steps
+ // are dropped on submit (a user adding a row then leaving it blank shouldn't create a titleless step).
+ const canSave = () => goal().trim().length > 0 && steps().some((s) => s.title.trim().length > 0)
+
+ const onSave = async () => {
+ if (busy() || !canSave()) return
+ setBusy(true)
+ try {
+ const plan: GoalPlanInput = {
+ goal: goal().trim(),
+ steps: steps()
+ .filter((s) => s.title.trim().length > 0)
+ .map((s) => ({
+ ...(s.step_id ? { step_id: s.step_id } : {}),
+ title: s.title.trim(),
+ status: s.status,
+ })),
+ }
+ const ok = await editPlanGoal(props.client, props.sessionID, plan)
+ if (ok) {
+ showToast({ title: language.t("composer.goal.editPlan.saved"), variant: "success" })
+ props.onClose()
+ } else {
+ showToast({ title: language.t("composer.goal.editPlan.failed") })
+ }
+ } catch {
+ showToast({ title: language.t("composer.goal.editPlan.failed") })
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/packages/app/src/components/deepagent/goal-start-button.tsx b/packages/app/src/components/deepagent/goal-start-button.tsx
index 89a9bb9d..24d0148f 100644
--- a/packages/app/src/components/deepagent/goal-start-button.tsx
+++ b/packages/app/src/components/deepagent/goal-start-button.tsx
@@ -105,7 +105,7 @@ export function GoalStartButton(props: { sessionID: string }) {
data-component="goal-start-button"
class="flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-surface-raised border border-border-subtle text-13-regular"
>
-
+ {language.t("goal.start.hint")}
+
diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx
index 4f436f86..6ec5f86a 100644
--- a/packages/app/src/components/status-popover-body.tsx
+++ b/packages/app/src/components/status-popover-body.tsx
@@ -1,12 +1,11 @@
import { Button } from "@deepagent-code/ui/button"
import { useDialog } from "@deepagent-code/ui/context/dialog"
import { Icon } from "@deepagent-code/ui/icon"
-import { Switch } from "@deepagent-code/ui/switch"
import { Tabs } from "@deepagent-code/ui/tabs"
-import { showToast } from "@/utils/toast"
import { useNavigate } from "@solidjs/router"
-import { type Accessor, createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
+import { type Accessor, createEffect, createMemo, For, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store"
+import { McpManagement } from "@/components/mcp-management"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
@@ -14,9 +13,6 @@ import { ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { type ServerHealth } from "@/utils/server-health"
import { useGlobal } from "@/context/global"
-import { useMcpRemove, useMcpToggle } from "@/context/mcp"
-import { DialogAddMcp } from "@/components/dialog-add-mcp"
-import { DialogConfigureMcp } from "@/components/dialog-configure-mcp"
const listServersByHealth = (
list: ServerConnection.Any[],
@@ -246,14 +242,6 @@ export function StatusPopoverBody(props: { shown: Accessor }) {
const language = useLanguage()
const navigate = useNavigate()
- const fail = (err: unknown) => {
- showToast({
- variant: "error",
- title: language.t("common.requestFailed"),
- description: err instanceof Error ? err.message : String(err),
- })
- }
-
createEffect(() => {
if (!props.shown()) return
})
@@ -265,30 +253,11 @@ export function StatusPopoverBody(props: { shown: Accessor }) {
dialogRun += 1
})
const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health))
- const toggleMcp = useMcpToggle()
- const removeMcp = useMcpRemove()
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
- const [selectedMcp, setSelectedMcp] = createSignal()
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
- createEffect(() => {
- const names = mcpNames()
- if (names.length === 0) {
- setSelectedMcp(undefined)
- return
- }
- if (!selectedMcp() || !names.includes(selectedMcp()!)) setSelectedMcp(names[0])
- })
-
- const deleteSelectedMcp = () => {
- const name = selectedMcp()
- if (!name || removeMcp.isPending) return
- if (!window.confirm(language.t("dialog.mcp.delete.confirm", { name }))) return
- removeMcp.mutate(name)
- }
-
return (
isDesktop() && view().rightPanel.mode() === "review")
const desktopFileTreeOpen = createMemo(() => isDesktop() && view().rightPanel.mode() === "files")
const desktopRightPanelOpen = createMemo(() => isDesktop() && view().rightPanel.opened())
+ // T3.2: the icon rail is ALWAYS present on desktop, so the session content always yields the rail
+ // width; the content-panel width (wide bucket for review/files, else narrow) is subtracted on top only
+ // when a panel is open.
const sessionPanelWidth = createMemo(() => {
- if (!desktopRightPanelOpen()) return "100%"
- return `calc(100% - ${layout.rightPanel.width()}px)`
+ if (!isDesktop()) return "100%"
+ const mode = view().rightPanel.mode()
+ const bucket = mode === "review" || mode === "files" ? "wide" : "narrow"
+ const content = desktopRightPanelOpen() ? layout.rightPanel.width(bucket) : 0
+ return `calc(100% - ${RIGHT_PANEL_RAIL_PX + content}px)`
})
+ // The composer is only truly centered when the panel content is closed AND the rail (a thin 44px
+ // strip) is the only thing on the right — visually still effectively centered.
const centered = createMemo(() => isDesktop() && !desktopRightPanelOpen())
function normalizeTab(tab: string) {
diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx
index d8f441fa..23217bcb 100644
--- a/packages/app/src/pages/session/composer/session-composer-region.tsx
+++ b/packages/app/src/pages/session/composer/session-composer-region.tsx
@@ -2,6 +2,7 @@ import { Show, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useNavigate } from "@solidjs/router"
import { useSpring } from "@deepagent-code/ui/motion-spring"
+import { Icon } from "@deepagent-code/ui/icon"
import { useLayout } from "@/context/layout"
import { PromptInput } from "@/components/prompt-input"
import { useLanguage } from "@/context/language"
@@ -58,6 +59,10 @@ export function SessionComposerRegion(props: {
const view = layout.view(route.sessionKey)
const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt)
+ // V4.1 §S1 — when the session is WORKING, a message the user sends is absorbed as a steer for the
+ // running turn (the server buffers it at the next turn boundary, never a BusyError). Surface a subtle
+ // hint so the user knows the send won't be dropped or start a competing turn.
+ const working = createMemo(() => (route.params.id ? sync.data.session_working(route.params.id) : false))
const info = createMemo(() => (route.params.id ? sync.session.get(route.params.id) : undefined))
const parentID = createMemo(() => info()?.parentID)
const child = createMemo(() => !!parentID())
@@ -254,6 +259,15 @@ export function SessionComposerRegion(props: {
+
+
+
+ {language.t("composer.steer.hint")}
+
+ ({
describe("submitCreateGroup", () => {
test("creates a group via the client (never window.prompt)", async () => {
- const createGroup = mock(async (p: { name: string; type: "project" | "system" }) => group("grp_1", p.name))
+ const createGroup = mock(async (p: { name: string; type: "project" | "system" | "direct" }) =>
+ group("grp_1", p.name),
+ )
const result = await submitCreateGroup(" Design ", createGroup)
expect(createGroup).toHaveBeenCalledTimes(1)
diff --git a/packages/app/src/pages/session/im-panel-helpers.ts b/packages/app/src/pages/session/im-panel-helpers.ts
index 7dad9b3b..63c7526a 100644
--- a/packages/app/src/pages/session/im-panel-helpers.ts
+++ b/packages/app/src/pages/session/im-panel-helpers.ts
@@ -11,7 +11,12 @@ export type CreateGroupResult =
export async function submitCreateGroup(
rawName: string,
- createGroup: (payload: { name: string; type: "project" | "system"; projectID?: string }) => Promise,
+ createGroup: (payload: {
+ name: string
+ type: "project" | "system" | "direct"
+ projectID?: string
+ member?: { memberID: string; memberType: "user" | "agent" }
+ }) => Promise,
): Promise {
const name = rawName.trim()
if (!name) return { skipped: true }
@@ -22,3 +27,25 @@ export async function submitCreateGroup(
return { error: error instanceof Error ? error.message : String(error) }
}
}
+
+// §B3 私聊 — create a "direct" 1:1 group with a counterparty (a user or an agent). The server user is
+// always the first participant; `member` names the second. Returns the same result shape.
+export async function submitCreateDirect(
+ name: string,
+ member: { memberID: string; memberType: "user" | "agent" },
+ createGroup: (payload: {
+ name: string
+ type: "project" | "system" | "direct"
+ projectID?: string
+ member?: { memberID: string; memberType: "user" | "agent" }
+ }) => Promise,
+): Promise {
+ const trimmed = name.trim() || member.memberID
+ if (!member.memberID.trim()) return { skipped: true }
+ try {
+ const group = await createGroup({ name: trimmed, type: "direct", member })
+ return { group }
+ } catch (error) {
+ return { error: error instanceof Error ? error.message : String(error) }
+ }
+}
diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx
index 727d2820..424b88e0 100644
--- a/packages/app/src/pages/session/session-side-panel.tsx
+++ b/packages/app/src/pages/session/session-side-panel.tsx
@@ -1,23 +1,25 @@
-import { For, Match, Show, Switch, createEffect, createMemo, createSignal, batch, type ComponentProps, type JSX } from "solid-js"
+import { For, Match, Show, Switch, createEffect, createMemo, createResource, createSignal, type ComponentProps, type JSX } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { Tabs } from "@deepagent-code/ui/tabs"
import { IconButton } from "@deepagent-code/ui/icon-button"
import { Icon } from "@deepagent-code/ui/icon"
-import { Keybind } from "@deepagent-code/ui/keybind"
+import { Tooltip } from "@deepagent-code/ui/tooltip"
import { ResizeHandle } from "@deepagent-code/ui/resize-handle"
import type { SnapshotFileDiff, VcsFileDiff } from "@deepagent-code/sdk/v2"
+import { RIGHT_PANEL_RAIL_PX, type RightPanelWidthBucket } from "@/context/layout"
import FileTree from "@/components/file-tree"
-import { StatusPopoverBody } from "@/components/status-popover-body"
+import { SidePanelMcp } from "@/pages/session/side-panel-mcp"
import { SidePanelPlugins } from "@/pages/session/side-panel-plugins"
import { useCommand } from "@/context/command"
import { useDebug } from "@/context/debug"
import { useFile, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
+import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
-import { useTerminal } from "@/context/terminal"
-import { createOpenSessionFileTab, createSessionTabs, focusTerminalById, type Sizing } from "@/pages/session/helpers"
+import { fetchCapabilities } from "@/components/deepagent/panel-goal.api"
+import { createOpenSessionFileTab, createSessionTabs, type Sizing } from "@/pages/session/helpers"
import { IdeFileEditor } from "@/pages/session/ide-file-editor"
import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
@@ -27,17 +29,63 @@ import { SidePanelWorktree } from "@/pages/session/side-panel-worktree"
import { SidePanelDebug } from "@/pages/session/side-panel-debug"
import { SidePanelProfile } from "@/pages/session/side-panel-profile"
import { SidePanelIM } from "@/pages/session/side-panel-im"
+import { SidePanelOversight } from "@/pages/session/side-panel-oversight"
type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
-type SidePanelItem = {
- icon: ComponentProps["name"]
- title: string
- keybind?: string
- badge?: string
- active: boolean
- onClick: () => void
+
+// T3.1 — the single source of truth for the right-panel entries. Every panel is one row here; the memo
+// boolean, the click handler, the rail button, and the content arm are all DERIVED from this
+// list, so adding a panel means adding ONE entry (not editing 5 parallel places as before). T3.3 adds
+// `group` (rail sectioning) and `bucket` (which remembered width this panel uses).
+type PanelMode =
+ | "review"
+ | "files"
+ | "subagents"
+ | "im"
+ | "oversight"
+ | "browser"
+ | "worktree"
+ | "mcp"
+ | "plugins"
+ | "debug"
+ | "profile"
+
+type PanelGroup = "code" | "agents" | "env" | "dev"
+
+type PanelDef = {
+ readonly mode: PanelMode
+ readonly icon: ComponentProps["name"]
+ readonly titleKey: string
+ readonly group: PanelGroup
+ // Wide panels (diff/files) get the wide remembered width; everything else the narrow one (T3.3).
+ readonly bucket: RightPanelWidthBucket
+ // Keybind command id (rendered on the rail tooltip), if any.
+ readonly keybind?: string
+ // Gate the entry on a server capability (T1.1). Absent ⇒ always shown.
+ readonly capability?: "oversight"
}
+// Order = rail order. Groups are contiguous so the rail can draw a divider between them.
+const PANELS: readonly PanelDef[] = [
+ // Code
+ { mode: "review", icon: "review", titleKey: "session.tab.review", group: "code", bucket: "wide", keybind: "review.toggle" },
+ { mode: "files", icon: "file-tree", titleKey: "settings.general.row.showFileTree.title", group: "code", bucket: "wide", keybind: "fileTree.toggle" },
+ // Agents
+ { mode: "subagents", icon: "agent-tree", titleKey: "session.subagents.title", group: "agents", bucket: "narrow" },
+ { mode: "im", icon: "bubble-5", titleKey: "session.tab.im", group: "agents", bucket: "narrow" },
+ { mode: "oversight", icon: "oversight", titleKey: "session.panel.oversight", group: "agents", bucket: "narrow", capability: "oversight" },
+ // Env
+ { mode: "browser", icon: "window-cursor", titleKey: "browser.title", group: "env", bucket: "narrow" },
+ { mode: "worktree", icon: "branch", titleKey: "worktree.title", group: "env", bucket: "narrow" },
+ // Dev
+ { mode: "mcp", icon: "mcp", titleKey: "status.popover.tab.mcp", group: "dev", bucket: "narrow" },
+ { mode: "plugins", icon: "plugin", titleKey: "status.popover.tab.plugins", group: "dev", bucket: "narrow" },
+ { mode: "debug", icon: "debug", titleKey: "session.panel.debug", group: "dev", bucket: "narrow" },
+ { mode: "profile", icon: "profile", titleKey: "session.panel.profile", group: "dev", bucket: "narrow" },
+]
+
+const GROUP_ORDER: readonly PanelGroup[] = ["code", "agents", "env", "dev"]
+
function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
return typeof value.file === "string"
}
@@ -60,12 +108,37 @@ export function SessionSidePanel(props: {
const debug = useDebug()
const language = useLanguage()
const command = useCommand()
- const terminal = useTerminal()
const sync = useSync()
+ const sdk = useSDK()
const { params, sessionKey, tabs, view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
+ // T1.1 — gate flag-off panel entries on the server's advertised capabilities so an entry never opens
+ // a permanently-empty dead-end. The Oversight Approval Queue's producers live behind
+ // v4MultiAgentRuntime (default OFF); when it is off the queue can never be fed, so we hide the icon
+ // rather than let a user open an empty panel with no way to populate it. Tolerant of an older server
+ // (fetch fails ⇒ all V4 flags treated OFF ⇒ entry hidden).
+ const [capabilities] = createResource(() =>
+ fetchCapabilities(sdk.client as unknown as Parameters[0]).catch(() => null),
+ )
+ const oversightEnabled = () => capabilities()?.v4MultiAgentRuntime ?? false
+
+ // T3.3 rail badge: the count of PENDING Approval-Queue items, so a supervisor sees a number on the
+ // Oversight icon without opening the panel. Only fetched when the capability is on (else the route
+ // fails-closed); best-effort — a failure just yields 0 (no badge).
+ const [oversightApprovals] = createResource(oversightEnabled, async (enabled) => {
+ if (!enabled) return 0
+ try {
+ const { fetchOversightApprovals } = await import("@/components/deepagent/oversight.api")
+ const items = await fetchOversightApprovals(sdk.client as unknown as Parameters[0])
+ return items.length
+ } catch {
+ return 0
+ }
+ })
+ const oversightPendingCount = () => oversightApprovals() ?? 0
+
// Subagents = child sessions (parentID === current). Surface a live count on the sidebar icon so
// the user sees a spawn happened without opening the panel; the running count (session_working)
// drives a pulsing badge, matching the running dot inside the panel list.
@@ -78,32 +151,21 @@ export function SessionSidePanel(props: {
() => subagentChildren().filter((s) => sync.data.session_working(s.id)).length,
)
- const menuOpen = createMemo(() => isDesktop() && view().rightPanel.mode() === "menu")
- const reviewOpen = createMemo(() => isDesktop() && view().rightPanel.mode() === "review")
- const fileOpen = createMemo(() => isDesktop() && view().rightPanel.mode() === "files")
- const statusOpen = createMemo(() => isDesktop() && view().rightPanel.mode() === "status")
- const subagentsOpen = createMemo(() => isDesktop() && view().rightPanel.mode() === "subagents")
- const browserOpen = createMemo(() => isDesktop() && view().rightPanel.mode() === "browser")
- const worktreeOpen = createMemo(() => isDesktop() && view().rightPanel.mode() === "worktree")
- const pluginsOpen = createMemo(() => isDesktop() && view().rightPanel.mode() === "plugins")
- const debugOpen = createMemo(() => isDesktop() && view().rightPanel.mode() === "debug")
- const profileOpen = createMemo(() => isDesktop() && view().rightPanel.mode() === "profile")
- const imOpen = createMemo(() => isDesktop() && view().rightPanel.mode() === "im")
- const open = createMemo(
- () =>
- menuOpen() ||
- reviewOpen() ||
- fileOpen() ||
- statusOpen() ||
- subagentsOpen() ||
- browserOpen() ||
- worktreeOpen() ||
- pluginsOpen() ||
- debugOpen() ||
- profileOpen() ||
- imOpen(),
+ // T3.1: the active panel mode (desktop only), derived once from the layout store instead of one memo
+ // per panel. `isActive(mode)` and `open()` fall out of it.
+ const activeMode = createMemo(() => {
+ if (!isDesktop()) return undefined
+ const mode = view().rightPanel.mode()
+ return mode && PANELS.some((p) => p.mode === mode) ? (mode as PanelMode) : undefined
+ })
+ const isActive = (mode: PanelMode) => activeMode() === mode
+ const open = createMemo(() => activeMode() !== undefined)
+ // Per-panel remembered width (T3.3): the wide bucket for diff/files, the narrow bucket for the rest.
+ const activeBucket = createMemo(
+ () => PANELS.find((p) => p.mode === activeMode())?.bucket ?? "wide",
)
- const panelWidth = createMemo(() => (open() ? `${layout.rightPanel.width()}px` : "0px"))
+ const contentWidth = createMemo(() => layout.rightPanel.width(activeBucket()))
+ const panelWidth = createMemo(() => (open() ? `${contentWidth()}px` : "0px"))
const diffs = createMemo(() => props.diffs().filter(renderDiff))
const diffFiles = createMemo(() => diffs().map((d) => d.file))
@@ -205,7 +267,7 @@ export function SessionSidePanel(props: {
const res = await file.createFile(name)
if (!res.ok) {
const { showToast } = await import("@/utils/toast")
- showToast({ variant: "error", title: "创建失败", description: res.error ?? "unknown" })
+ showToast({ variant: "error", title: language.t("session.files.create.failed"), description: res.error ?? "unknown" })
} else {
void file.load(name)
}
@@ -213,7 +275,7 @@ export function SessionSidePanel(props: {
const res = await file.mkdir(name)
if (!res.ok) {
const { showToast } = await import("@/utils/toast")
- showToast({ variant: "error", title: "创建文件夹失败", description: res.error ?? "unknown" })
+ showToast({ variant: "error", title: language.t("session.files.createFolder.failed"), description: res.error ?? "unknown" })
}
}
}
@@ -225,144 +287,39 @@ export function SessionSidePanel(props: {
layout.fileTree.setTab(value)
}
- const openMenu = () => {
- view().reviewPanel.close()
- layout.fileTree.close()
- view().rightPanel.open("menu")
- }
-
- const openReview = () => {
- view().reviewPanel.close()
- layout.fileTree.close()
- view().rightPanel.open("review")
- }
-
- const openFiles = () => {
- view().reviewPanel.close()
- layout.fileTree.close()
- view().rightPanel.open("files")
- }
-
- const openStatus = () => {
+ // T3.2: switch to a panel with ONE click (was 2 — return-to-menu then pick). Clicking the active
+ // panel's rail icon closes the panel (toggle), matching the titlebar toggle.
+ const openPanel = (mode: PanelMode) => {
view().reviewPanel.close()
layout.fileTree.close()
- view().rightPanel.open("status")
- }
-
- const openSubagents = () => {
- view().reviewPanel.close()
- layout.fileTree.close()
- view().rightPanel.open("subagents")
- }
-
- const openBrowser = () => {
- view().reviewPanel.close()
- layout.fileTree.close()
- view().rightPanel.open("browser")
- }
-
- const openWorktree = () => {
- view().reviewPanel.close()
- layout.fileTree.close()
- view().rightPanel.open("worktree")
- }
-
- const openPlugins = () => {
- view().reviewPanel.close()
- layout.fileTree.close()
- view().rightPanel.open("plugins")
- }
-
- const openDebug = () => {
- view().reviewPanel.close()
- layout.fileTree.close()
- view().rightPanel.open("debug")
- }
-
- const openProfile = () => {
- view().reviewPanel.close()
- layout.fileTree.close()
- view().rightPanel.open("profile")
- }
-
- const openIM = () => {
- view().reviewPanel.close()
- layout.fileTree.close()
- view().rightPanel.open("im")
+ if (isActive(mode)) {
+ view().rightPanel.close()
+ return
+ }
+ view().rightPanel.open(mode)
}
-
- const openTerminal = () => {
- view().terminal.open()
- queueMicrotask(() => {
- const id = terminal.active()
- if (id) focusTerminalById(id)
- })
+ // T3.2: a content panel's close button now CLOSES the panel (was: return to the menu list). The rail
+ // stays visible, so the user re-opens with one click.
+ const closePanel = () => view().rightPanel.close()
+
+ // T3.1/T3.3: the rail entries, derived from PANELS — capability-gated (T1.1), with a live badge count
+ // per panel. review-change-count / running-subagent-count / oversight-pending feed `item.badge`; the
+ // rail renders whatever number is present.
+ const badgeFor = (mode: PanelMode): number | undefined => {
+ if (mode === "review") return props.hasReview() ? props.reviewCount() : undefined
+ if (mode === "subagents") return runningSubagentCount() > 0 ? runningSubagentCount() : undefined
+ if (mode === "oversight") return oversightPendingCount() > 0 ? oversightPendingCount() : undefined
+ return undefined
}
-
- // V3.6 Phase 3B: removed duplicate "terminal" and "status/servers-MCP" entries.
- // Both already appear as titlebar buttons (title-bar right: terminal toggle + StatusPopover).
- // Keeping them here created confusing dual entry-points for the same functionality (S1).
- const menuItems = createMemo(() => [
- {
- icon: "review",
- title: language.t("session.tab.review"),
- keybind: command.keybind("review.toggle"),
- badge: props.hasReview() ? String(props.reviewCount()) : undefined,
- active: reviewOpen(),
- onClick: openReview,
- },
- {
- icon: "file-tree",
- title: language.t("settings.general.row.showFileTree.title"),
- keybind: command.keybind("fileTree.toggle"),
- active: fileOpen(),
- onClick: openFiles,
- },
- {
- icon: "task",
- title: language.t("session.subagents.title"),
- badge: runningSubagentCount() > 0 ? String(runningSubagentCount()) : undefined,
- active: subagentsOpen(),
- onClick: openSubagents,
- },
- {
- icon: "bubble-5",
- title: language.t("session.tab.im"),
- active: imOpen(),
- onClick: openIM,
- },
- {
- icon: "link",
- title: language.t("browser.title"),
- active: browserOpen(),
- onClick: openBrowser,
- },
- {
- icon: "branch",
- title: language.t("worktree.title"),
- active: worktreeOpen(),
- onClick: openWorktree,
- },
- {
- icon: "dot-grid",
- // V3.6 Phase 3C: renamed from "Plugins" to "Extensions & Services" (sidebar.extensions)
- title: language.t("sidebar.extensions"),
- active: pluginsOpen(),
- onClick: openPlugins,
- },
- {
- icon: "terminal",
- title: "调试",
- active: debugOpen(),
- onClick: openDebug,
- },
- {
- icon: "status",
- title: "性能剖析",
- active: profileOpen(),
- onClick: openProfile,
- },
- ])
+ const railItems = createMemo(() =>
+ PANELS.filter((p) => (p.capability === "oversight" ? oversightEnabled() : true)).map((p) => ({
+ ...p,
+ title: language.t(p.titleKey),
+ keybindLabel: p.keybind ? command.keybind(p.keybind) : undefined,
+ active: isActive(p.mode),
+ badge: badgeFor(p.mode),
+ })),
+ )
createEffect(() => {
if (!file.ready()) return
@@ -386,43 +343,55 @@ export function SessionSidePanel(props: {
})
return (
+ // T3.4 — the entire right panel (rail + content) is DESKTOP-ONLY (≥768px). This is a deliberate
+ // simplification, not an oversight: on a narrow window the session/composer takes the full width and
+ // the panel's functions are reached other ways (titlebar toggles, commands). No mobile drawer is
+ // provided. Revisit only if the product targets tablet/phone widths as a first-class surface.
+ {/* T3.2 — aside = a fixed, always-on icon RAIL + an animated CONTENT region. The rail is always
+ interactive (it's the switcher); only the content region collapses to 0 width when closed. */}
)
}
-function SidePanelMenu(props: { items: () => SidePanelItem[]; onClose: () => void }) {
- const language = useLanguage()
+type RailItem = {
+ readonly mode: PanelMode
+ readonly icon: ComponentProps["name"]
+ readonly title: string
+ readonly group: PanelGroup
+ readonly active: boolean
+ readonly badge?: number
+ readonly keybindLabel?: string
+}
+// T3.2/T3.3 — the always-on vertical icon rail. ~44px fixed strip; one icon per panel, grouped with a
+// divider between groups (T3.3). A click switches the content panel in ONE step (or closes it if that
+// panel is already active). Each icon carries a tooltip (title + keybind) and an optional badge count.
+function SidePanelRail(props: {
+ items: () => RailItem[]
+ groupOrder: readonly PanelGroup[]
+ onSelect: (mode: PanelMode) => void
+ collapseLabel: string
+}) {
return (
-
-
-
-
- {/* Scrollable so items stay reachable when the bottom terminal shortens the
- sidebar. Center vertically only while there's spare room (justify-center on
- a min-h-full inner track); once items overflow, scrolling takes over. */}
-
-
+
性能剖析
{
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\n${chunk.text}\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
+// `_