# ARCHITECTURE.md — tianshu-mcp Architecture > Applies to version `0.9.4` (2026-10-09). > This document describes the **system structure and module boundaries** for developers who will modify this repository; the version marker is only bumped on release commits, and the latest release notes live in `docs/release-v.md`. > This document describes the **system structure and module boundaries** for developers who will modify this repository. > For installation, usage, and host integration see [README.en.md](README.en.md); for handover status, troubleshooting, and lessons learned see [HANDOFF.md](HANDOFF.md). > Chinese version: [ARCHITECTURE.md](ARCHITECTURE.md). --- ## 1. Positioning and system context `tianshu-mcp` is an **orchestration layer that Tianshu consumes as a standard MCP server**. Tianshu is the commander and the user-facing surface; this server owns three things: 1. **Scheduling** — task queue, concurrency gate, state machine, timeouts, cancellation. 2. **Execution surface** — delivering the task brief to an external AI agent (Codex desktop, TraeWork, ZCode, or any CLI). 3. **Objective acceptance** — verifying against a git baseline with command checks, code analysis, and optional visual pixel comparison, then producing a report. ```text ┌─────────────────────────────────────────────────────────────┐ │ Tianshu (TUI × GUI) │ │ · Calls tools/call synchronously, one call at a time, │ │ consuming only content[].text and isError │ └───────────────────────────┬─────────────────────────────────┘ │ MCP over stdio (stdout carries JSON-RPC only) ┌───────────────────────────▼─────────────────────────────────┐ │ tianshu-mcp (this repository) │ │ scheduling ── execution surface ── acceptance instrument │ └──────────┬──────────────────────────────┬───────────────────┘ │ │ ┌───────▼────────┐ ┌────────▼─────────┐ │ External agent │ │ Target workspace │ │ GUI: drive UI │ │ git repo + tests │ │ over CDP │ │ .tianshu-mcp/ │ │ CLI: subprocess │ └──────────────────┘ └────────────────┘ ``` ### 1.1 Four hard constraints and the architecture they forced This project's shape is not a free design choice — it was forced by four **measured constraints**. Read this table before changing the architecture: | # | Measured constraint | Architectural consequence | |---|---|---| | C1 | Tianshu's MCP tool call **returns text only**: `content[]` `text` items are concatenated and `isError` passes through | Every result is "human-readable text + a `---tianshu-mcp-meta---` JSON block"; no resources or prompts (`src/mcp/formatter.ts`) | | C2 | Tianshu calls `tools/call` **synchronously, per call** | Long tasks must be async: `run_task` returns a `taskId` immediately and `query_task` polls. There is no server push | | C3 | TraeWork agent requests are TDE-encrypted at the TTNet layer and cannot be constructed outside the client | The only viable path is **driving the desktop UI over CDP** and extracting results from the DOM (`src/agents/traework/`) | | C4 | The Codex desktop app is an **MSIX store package**; a GUI host cannot `CreateProcess` it directly | It must be activated through `IApplicationActivationManager` COM with an injected dedicated `--user-data-dir` before a CDP port opens (`src/agents/codex/launcher.ts`) | ZCode falls into the same class: it ships no headless CLI, so it is also CDP-driven (settled by measurement in M2). --- ## 2. Layered architecture ```text ┌──────────────────────────────────────────────────────────────────┐ │ L1 Protocol edge src/index.ts · src/server.ts · src/mcp/ │ │ entry & CLI dispatch · assembly · 8 tool registrations │ │ parameter validation · text+meta formatting │ ├──────────────────────────────────────────────────────────────────┤ │ L2 Task domain src/tasks/ │ │ state machine · per-project serial queue · global concurrency │ │ gate · event-stream persistence · cancellation semantics │ ├──────────────────────────────────────────────────────────────────┤ │ L3 Orchestration src/loop/ │ │ TaskOrchestrator: dispatch → verify → rework → re-verify; │ │ round accounting and termination decisions │ ├───────────────────────────────┬──────────────────────────────────┤ │ L4a Execution src/agents/ │ L4b Acceptance src/verify/ │ │ AgentAdapter contract │ src/visual/ │ │ CLI spawn / GUI CDP │ git baseline · command checks │ │ │ code analysis · visual diff │ │ │ report artifacts │ ├───────────────────────────────┴──────────────────────────────────┤ │ L5 Foundation src/config/ · src/util/ │ │ zod schema · data home · hot reload · atomic writes · path │ │ normalization · logging · skill self-install │ └──────────────────────────────────────────────────────────────────┘ ``` Dependencies point **strictly downward**: L1 → L2 → L3 → {L4a, L4b} → L5. L4a and L4b do not depend on each other and meet only at L3. That is the most important decoupling boundary in the project: **"who does the work" and "how we judge the work" are two independently replaceable concerns**. --- ## 3. Startup assembly and lifecycle ### 3.1 Entry dispatch (`src/index.ts`) ```text node dist/index.js → stdio MCP server node dist/index.js visual → visual acceptance CLI subcommands (dispatched before any stdio connection, so it never occupies the protocol stream) ``` - The data home is resolved by `resolveDataHome()`: env `TIANSHU_MCP_HOME` wins, otherwise `~/.tianshu-mcp`. - The logger initializes to `/logs/`. - Skill self-install can be disabled with `--no-skill-install` or `TIANSHU_MCP_NO_SKILL_INSTALL=1`; a "needs change but not auto-overwritable" directory (`autoInstall:"prompt"` or unknown source) can be approved with `--approve-skill-update` or `TIANSHU_MCP_APPROVE_SKILL_UPDATE=1` (see §3.4). - Shutdown path: `SIGINT` / `SIGTERM` / stdin EOF / stdin close → archive active tasks and terminate child processes → `exit(0)`. ### 3.2 Assembly order (`src/server.ts`) `buildServer()` is the single assembly point, and its order carries meaning: ```text resolveDataHome → Logger → DataHome(BUILTIN_PROFILES) → init() → loadConfig() → maxRunning → TaskStore → AgentAdapterRegistry(loadProfiles) → AcceptanceEngine → TaskManager(+makeBuildCtx) → manager.initialize({maxRunning, guiStopWaitMs}) # archive leftover active tasks → skill self-install (background, does not block the handshake) → register 8 tools → return ServerAssembly{server, manager, dataHome, store, logger, close} ``` `close()` = `manager.shutdownInterrupt()` (archive active tasks, terminate child processes) → `engine.close()` → `server.close()`. Tool registration is **data-driven**: it walks `TOOL_DEFS`, looks up each name in `handlers`, logs an error and skips if missing, and passes along `_meta.requireApproval`, `_meta.capability`, and the MCP `annotations` (readOnly / destructive / openWorld / idempotent — `idempotentHint` is true only for `run_task` / `verify_task`, and only means anything when the caller supplies `idempotencyKey`; see §5.6) for the host's policy layer. ### 3.3 Data home layout ```text / default ~/.tianshu-mcp ├── config.json server config (concurrency, timeouts, skills) ├── agent-profiles.json user-defined / overriding agent profiles ├── projects.json project registry (incl. per-project verify config) ├── idempotency.json idempotency mapping (key→taskId/digest, TTL + capacity pruning; issue #15) ├── logs/server.log all-level diagnostic log (same source as stderr) ├── browsers/ managed Chrome for the visual module ├── visual-candidates// pending baseline candidates (candidate.json + PNGs + preview.html) ├── visual-locks/.lock mutual exclusion for visual operations └── tasks// per-task isolated directory (below) ``` Per-task directory (`src/tasks/task-store.ts`): | File | Contents | |---|---| | `task.jsonl` | Append-only event stream (the authoritative timeline) | | `task.json` | Latest `TaskMeta` snapshot (atomic write) | | `baseline.json` | Git baseline captured before work started | | `agent-.log` | Agent output (round is 0-based) | | `verify-.log` | Verification command output | | `report-.md` / `.json` | Acceptance report (`.md` for humans, `.json` for machines) | | `report-.html` | Offline visual report, **only when the report contains visual results** | | `rework--r.md` | Repair plan (non-Codex path) | | `qoder-session.json` | Qoder CN send checkpoint (**Qoder only**, see §8.2) | | `visual////` | Visual quadruple: `actual/baseline/diff/regions.png` + `metrics.json` | | `visual-snapshot.json` | Visual rules snapshot frozen for the task's duration | Project-side artifacts: `/.tianshu-mcp/acceptance.json` (project-level acceptance config), `/tests/visual/baselines/...` (visual baselines), and for the Codex path `/.zcode/plans/codex-fix-r.md` (repair plan). ### 3.4 Trust and decision model of skill self-install (issue #16) Module `src/util/skill-install.ts`, run **in the background** inside `buildServer()` (`void`, never blocking the handshake); failures only warn and never block the server. **Source location**: resolved relative to the package via `import.meta.url` only (`/../../skills/tianshu-mcp`) — a source run and a dist run have the same relative depth, so one candidate suffices. It **deliberately does not fall back to `process.cwd()`**: any cwd-based content discovery turns "debugging the server inside some third-party repository" into a poisoning surface. When no source is found, install is skipped with a warning. **Install manifest**: kept inside the target at `/.tianshu-mcp-install.json` (`schema`/`name`/`packageVersion`/`contentHash`/`installedAt`/`sourceDir`, plus `pendingUpdate` when a `"prompt"` hold is recorded). `contentHash` comes from `hashSkillTree()`, which **excludes the manifest itself** (otherwise writing the manifest would prove the target changed) and platform noise (`.DS_Store`/`Thumbs.db`/`desktop.ini`/`._*`/`.git*`). Copying uses the same exclusion predicate, so "hash right after install" equals the source hash exactly (the root of idempotency). **Decision matrix** (`decideInstall()`, a pure function separating decision from IO): | Target state | Criterion | `auto` (default) | `"prompt"` | plus `--approve-skill-update` | |---|---|---|---|---| | Absent | — | install | install | install | | Present, content == package | — | skip (repair/calibrate manifest if needed) | skip | skip | | Manifest ok, content == manifest record ≠ package | untouched stale package copy (trusted) | **backup + overwrite** (warn) | keep + warn + `pendingUpdate` in manifest | **backup + overwrite** (warn) | | Manifest ok, content ≠ manifest record | local edits (confirmed) | **keep + loud warn** | same | **same (approval has no effect)** | | No valid manifest (missing/corrupt/not a dir) and content ≠ package | unknown source | **keep + warn** | same | **backup + overwrite** (warn) | - With `skills.autoInstall: false`, `server.ts` short-circuits and never calls this module; `--no-skill-install` and `false` outrank the approval flag. - An overwrite happens **only** in the "trusted stale copy" and "explicitly approved" cells; `--approve-skill-update` has **no effect** on confirmed local edits (your skill-document tuning is never silently clobbered). **Atomic install**: `installFromSource()` follows "copy into `.incoming--` (manifest included) → rename the old directory to `.bak-` → rename into place"; on failure it removes the tmp tree and rolls the backup back (a failed rollback only warns and the backup remains). Stale `.incoming-*` directories older than one hour are cleaned on startup. **Log levels**: skip / manifest repair = `INFO`; stale-copy upgrade, local edits kept, unknown source, install failure = `WARN`. Searchable stable markers: `含本地修改`, `来源不明`, `未自动覆盖`. **Backup governance**: after a successful overwrite, `skills.backupKeep` (default 3, `0` = never prune) prunes — matching only directories named exactly `.bak-`, keeping the newest N by timestamp and logging deletions at `INFO`; a failed delete only warns. The first-install and skip/keep paths never prune. --- ## 4. MCP tool surface and return contract Thirteen tools (`src/mcp/tools.ts`), split into three families: `read` (queries, no side effects), `write` (side effects, all require approval), and `execute` (runs project-side commands without modifying sources; currently only `verify_task`, still approval-free per R11): | Tool | Capability | Approval | Purpose | |---|---|---|---| | `run_task` | write | yes | Dispatch work; returns a `taskId` asynchronously | | `manage_task` | write | yes | Task lifecycle management (merges the former `cancel_task` / `continue_task` / `rework_task`): `action=cancel` cancels (CLI kills the process tree; GUI best-effort stop click + bounded wait); `action=continue` resumes a `needs_user` task in its original session; `action=rework` manual rework feeding the failure summary back to the same agent. **Annotation `destructiveHint=true`** (tool-level; cannot vary per action) | | `query_task` | read | no | Poll status / progress / log tail | | `query_info` | read | no | Unified info lookup (merges the former `list_tasks` / `get_task_report` / `get_profiles`): `type=tasks` historical task list; `type=report` full text of a given round's `report.md`; `type=profiles` agent support status and executable discovery results | | `cancel_task` | write | yes | Cancel (CLI: kill the process tree; GUI: best-effort stop click + bounded wait) | | `verify_task` | **execute** | no | Run one verification over a task or project path: it runs project commands and may produce build artifacts, but **does not modify sources**, hence approval-free | | `wait_task` | read | no | Block until a single task reaches a stop point (terminal status or `needs_user`) or the timeout elapses; read-only, harmless | | `rework_task` | write | yes | Manual rework; feeds the failure summary back to the same agent | | `prepare_visual_baseline` | write | yes | Produce a baseline candidate and digest (does not adopt a baseline) | | `approve_visual_baseline` | write | yes | After user review, check the digest and write the baseline | > **`readOnlyHint` derivation**: `server.ts` emits MCP `readOnlyHint` from `capability === "read"`, so `verify_task` reports **false** for it (as of v0.6.1; it was wrongly `true` before). **`readOnlyHint` is not an approval signal** — approval is carried separately by `_meta.requireApproval`, which stays `false` for `verify_task`. **Return contract** (`src/mcp/formatter.ts`): human-readable body plus a trailing meta block the host can extract with a regex. ```text ---tianshu-mcp-meta--- { ...MetaBlockFields: taskId, status, ok, round, changedFiles, diffstat, ... } ---tianshu-mcp-meta--- ``` **Validation happens in two stages**: `server.ts` first runs `inputSchema.safeParse()` for protocol-level validation (failures return `Error: 参数不合法 — : `); handlers then apply semantic gates, such as the `projectPath` safety check (absolute, exists, realpath-normalized, rejecting the home directory and root-level/system directories), rejecting `mode` for anything but TraeWork, and allowing `allowCreateProject` only for ZCode. Progress is **persisted, never pushed**: GUI adapters report on the `gui.progressIntervalMs` cadence (default 30 s), `TaskOrchestrator` writes a `note` event into `task.jsonl` and refreshes the snapshot's `progressSummary` / `lastRunSignal`, and `query_task` reads the latest snapshot plus event stream on each call. A poller therefore sees the *last persisted* progress. Besides that free-text progress there is a **semantic** fine-grained event stream (`onEvent`, issue #18), see §5.8 — both are written into the same `task.jsonl`: `note` carries free text, fine-grained events carry node semantics. --- ## 5. Task domain: state machine, queue, persistence ### 5.1 State machine (`src/tasks/task.ts`) ```text ┌──────────────────────────────┐ run_task ──► queued ─┤──► running ──► verify_start ──┤──► succeeded │ │ ▲ │ │──► failed │ │ │ ▼ │──► needs_attention │ │ └──── fixing ◄──────────┘ │ │ │ ├──► needs_user ──► queued (resumed by manage_task(action='continue')) │ ├──► cancelled │ └──► interrupted └──► cancelled | interrupted ``` - **Statuses**: `queued, running, verify_start, fixing, succeeded, failed, needs_attention, needs_user, cancelled, interrupted`. - **Terminal**: `succeeded, failed, needs_attention, cancelled, interrupted`. - **Active**: `queued, running, verify_start, fixing`. - **`needs_user` is neither active nor terminal** — it can be resumed to `queued` by `manage_task(action="continue")`, or cancelled. This is the host-visible form of a GUI agent waiting for a human. - `TRANSITIONS` enumerates legal moves explicitly; `TaskStore.updateStatus` additionally guards that "a terminal state can only be re-entered by an explicit continue/rework". - `errorType`: `timeout | spawn | setup_failed | agent_failed | verify_failed | cancelled | interrupted | agent_unresolved | internal`. (`setup_failed` = **logical** failure during setup (mode not ready / project unbound / model unavailable) — retrying or changing environments will not help; `spawn` = infrastructure failure (process/install/CDP). Declared by the adapter via `AgentRunResult.errorType`; the orchestrator defaults to `spawn`.) ### 5.2 Persistence and crash recovery Two writes with different jobs: - **`task.jsonl` (authoritative event stream)**: append-only, with events such as `created / queued / started / verify_start / verify_round / fix_start / succeeded / failed / needs_attention / needs_user / continued / cancel_requested / cancelled / interrupted / timeout_killed / note`. Corrupt lines are skipped on read. - **`task.json` (query snapshot)**: atomic write (temp file + rename) for fast reads and reconstruction after a crash. Concurrent-write protection: `TaskStore` chains writes for the same task through `statusWriteTails` to prevent reordering, and `waitForStatusWrite()` is a **read barrier** ensuring a query or cancel never observes an in-memory terminal state before the JSONL event is flushed. **Crash recovery archives rather than resumes.** `TaskManager.initialize()` scans leftover active tasks and marks them all `interrupted` with `abortSource="shutdown"`. Rationale: GUI sessions and child processes are gone once the server exits, so silently resuming would produce un-attributable half-finished work. Recovery must be an explicit human `rework_task`. ### 5.3 Queue and concurrency | Mechanism | Rule | |---|---| | Global concurrency gate | `concurrency.maxRunning` (default 2, overridable by `maxRunningOverride`) | | Per-project serialization | Queues are bucketed by `queueKeyOf()`: the normalized `projectPath` in project mode, and the constant key `__zcode_default_workspace__` in no-project mode (never `undefined` / `""`) | | Scheduling | `pump()`: while `runningCount < maxRunning`, start one task for each project whose queue head is eligible; `projectBusy()` blocks a second concurrent task for the same project | | Task timeout guard | Beyond the orchestrator's own deadline, `startTask` arms an extra `taskTimeoutMs + 15s` timer in case the orchestrator itself hangs | ### 5.4 Cancellation semantics (by state) | State at cancel time | Behavior | |---|---| | `queued` | Removed from the queue → `cancelled` | | `needs_user` | Directly → `cancelled`; the terminal message states plainly that the GUI-side waiting session was not stopped (the MCP side holds no CDP connection) | | Active | `markCancelRequested` → abort → bounded poll for a terminal state (`CANCEL_SETTLE_TIMEOUT_MS = 30s`); returns `settled:false` when the stop is unconfirmed | | Terminal | No cancellation action; if the task carries the GUI pending marker (`guiResidualUnconfirmed`) this call performs the **manual acknowledgement** instead (terminal status unchanged, see §5.5) | Cancellation of a GUI agent is **best-effort and honestly reported**, but the adapters differ in what they can do: | Adapter | Clicks the in-UI stop button on cancel? | Reports `guiStop`? | |---|---|---| | Codex / Kimi Code / Qoder CN | Yes — clicks stop over CDP and bounded-waits (`cancelWaitMs`) for the GUI to go idle | Yes (when `idle=false` the terminal message must admit the stop is unconfirmed) | | ZCode / TraeWork | **No** — only stops MCP-side observation and keeps the instance | No (the terminal message states "no stop result to confirm") | Shutdown (`shutdownInterrupt()`, issue #14): abort every controller → **spawn tasks keep their bounded 2 s wait per task, while GUI tasks wait until the shared `shutdown.guiStopWaitMs` (15 s by default) global deadline** → mark both `running` and `queued` as `interrupted`. A GUI task's terminal message branches honestly on `guiStop` (confirmed / unconfirmed / no stop result) and **never** claims "the process has been terminated", which only holds for spawn children. ### 5.5 GUI terminal-state facts and manual acknowledgement (issue #14) | Field | Meaning | Written by | |---|---|---| | `TaskMeta.guiStop` | the GUI-side stop result `{clicked, idle}` of the most recent abort (persisted for **every** GUI agent) | `abortTerminal()` / run-result capture | | `TaskMeta.interruptedCleanStop` | whether this interruption is **confirmed** stopped (true only when `guiStop.idle === true`) | `abortTerminal()` / `persistInterrupted()` | | `TaskMeta.guiResidualUnconfirmed` | a GUI leftover archived on restart awaits manual confirmation (no connection exists at restart, so it is always set) | `initialize()`; cleared by `cancel_task` | | meta block `guiStopUnconfirmed` | the single read-side predicate: `guiResidualUnconfirmed===true \|\| interruptedCleanStop===false` | `metaFromTask()` | Decision matrix (the single implementation of red line 8, `guiStopDisclosure()`): `idle===true` → confirmed stopped; `idle===false` → a stop was attempted but is unconfirmed; **field absent → no stop result, and a stop must not be claimed either**. The window name is derived from `profile.displayName` (`guiAppNameOf()`, stripping descriptive parentheticals and generic suffixes, falling back to the original string when stripping would empty it) instead of hard-coding `agentId`. **Explicitly not done**: `initialize()` never reconnects over CDP to click stop — there is no session anchor after a restart and the adapters are fail-closed for instances without proof of ownership, so unattended clicking carries more risk than value. ### 5.6 Idempotency keys: retry-safe dispatch and verification (issue #15) `run_task` (both the project and project-less dispatch paths) and `verify_task` accept an optional `idempotencyKey`. The implementation lives in `src/tasks/idempotency.ts` (`IdempotencyIndex`) and is held by a closure inside `makeHandlers()` — it is **not** added to `AppContext`, so no construction site (including test fakes) has to change. | Decision | Implementation | |---|---| | Namespaces | `run_task` / `verify_task` are independent (map key is `scope\u0000key`) | | Argument digest | `canonicalDigest()`: stable serialisation (sorted object keys, `undefined` dropped) → same key with different arguments fails closed; the digest covers only the parsed arguments plus the normalised path, never runtime defaults or profile-derived values | | Hit decision | `lookup()` returns `hit` / `conflict` / `miss`; an entry past its TTL is a miss and is pruned lazily | | `run_task` hit | Always returns the original `taskId` and its current meta (terminal tasks included — read-only, never re-dispatched); a record whose task snapshot is unreadable counts as not-yet-effective and is re-dispatched | | `verify_task` hit | Finished → returns the recorded `reportRound` / verdict / report paths (nothing re-runs); running → a success result with `idempotencyReplay: "in_progress"` (never `isError`) | | Concurrency | `runExclusive()` serialises "re-check → write mapping → create task" per `(scope,key)`, so concurrent same-key calls never each create a task | | Crash window | The mapping is written **before** the task is created inside the same critical section, which is why `TaskManager.submit()` accepts an optional `taskId` | | Persistence | `/idempotency.json` (atomic writes, TTL and `maxEntries` pruning); a corrupt file is logged and rebuilt once from task snapshots via `TaskMeta.idempotencyKey/Scope/Digest` | | Write failure | Fail-open: the dispatched task is still returned, and the response and meta state that it cannot be replayed by key | | Key privacy | The raw key lives only in local snapshots and the mapping file; logs and event streams use `keyDigest()` (first 8 hex chars of sha256) | **The "in progress" marker is process-local** (`reserveInFlight` / `releaseInFlight`): an unfinished verification is not cached across a restart (there is no report to return, so a retry honestly re-runs), which also prevents a mapping stuck at `in_progress` while the engine is long dead. **Boundaries**: a `verify_task(taskId=…)` key is **not** written into the task snapshot (that field carries the task's dispatch key, so it is never overwritten), and rebuild coverage for that one combination relies on `idempotency.json`; no keys for `manage_task` / the visual baseline tools; no cross-process distributed idempotency (the same assumption the visual lock makes). ### 5.7 Terminal-state notifications: webhook hook (issue #22) For long tasks the caller previously had to babysit the UI. This capability asynchronously POSTs a JSON body to a configured URL on state transitions. | Decision | Implementation and rationale | |---|---| | The hook point is `TaskStore.updateStatus()` | It is the **single choke point** for state transitions. `TaskOrchestrator.finish()` only covers orchestrator-driven ends — `cancel()`'s queued branch, `initialize()`'s restart archiving, and `shutdownInterrupt()` / `persistInterrupted()` all bypass it | | Config lives in the global `config.json` (`notifications.webhook`) | Notification routing is a host/transport concern, not project acceptance policy; and `updateStatus` only has the data home, taskId and logger, so it cannot cheaply read project config on every transition | | "Exactly once" dedupes on `taskId + status + finishedAt` | `prev !== status` **cannot** be the gate: several paths (cancel's queued branch, `shutdownInterrupt`) **write `meta.status` directly first** and only then call `updateStatus`, at which point `prev` already equals the target state. `finishedAt` is refreshed by `updateStatus` on a terminal write and cleared by `rework`/`continueTask`, so repeated writes for one episode are suppressed while a **new episode after rework notifies again for the same status** | | Sending happens **after** `appendEvent` + `writeSnapshot` | Persist the local fact first, then notify outward | | `notify()` returns void (fire-and-forget) | Sending and retries run entirely in the background; a slow or dead endpoint **does not block the status-write chain**, and any exception only logs a `warn` | | Only **true terminal states** are subscribed by default | `needs_attention` (terminal) → `needs_human`; `needs_user` (**non-terminal**, restorable via continue, possibly re-entered later) is a separate class that is off by default — merging them would defeat "only push true terminal states by default" | | Global `fetch` + `AbortSignal.timeout` + `redirect:"manual"` | The same convention as `src/visual/services.ts`, built into Node ≥ 20, no new dependency | | Optional HMAC-SHA256 signature | Signs the **raw request body string** (`X-Tianshu-Signature: sha256=`); a receiver can recompute it with the same secret | | The notifier is an **optional** third constructor argument of `TaskStore` | Many tests construct `new TaskStore(home, logger)` directly, so backward compatibility is required | **Disclosed honestly**: best-effort delivery, **not guaranteed** — failures only log; delivery can still repeat across a server restart or a receiver retry (receivers should be idempotent); the body contains `projectPath` and local absolute paths to report files, so confirm the receiver is trusted before forwarding to a public service. See [task notifications](docs/notifications.en.md). ### 5.8 Fine-grained event stream: long-task observability (issue #18) `query_task` used to return only coarse status: for long tasks — especially GUI agents stuck on confirmation dialogs, file pickers or authorization prompts — callers could not tell "the agent is working normally" apart from "it is stuck waiting for a human". This capability lets adapters report semantic events at key nodes. The vocabulary lives in `src/agents/agent-events.ts` (**zero dependencies**, to avoid a cycle between `adapter.ts` / `tasks/task.ts` / `tasks/task-store.ts`): | Event | Meaning | |---|---| | `task_dispatched` | Instruction confirmed delivered to the agent | | `confirmation_dialog_detected` | A confirmation dialog was detected (including stale-dialog cleanup and the native "select folder" dialog) | | `awaiting_user_authorization` | Waiting for the user to authorize / log in / confirm | | `file_modification_started` | The agent started executing (**heuristic**: the first UI running signal; does not claim files were changed) | | `rework_triggered` | Entering rework after acceptance failed (emitted engine-side; `mode` distinguishes auto/manual) | | Decision | Implementation and rationale | |---|---| | Hook location | `AgentRunOptions.onEvent` (`src/agents/adapter.ts`), **not** the agent profile — `agent-profiles.json` is plain JSON and cannot hold a function; forcing one in would break `AgentProfilesFileSchema` parsing and hot reload. "Optional" is expressed by `opts.onEvent?.()` + `makeEmitter` | | Storage | Written into the **existing** `task.jsonl` (reusing `TaskStore.appendEvent`), **not** an in-memory ring buffer: during long GUI tasks the host may restart and a purely in-memory queue would lose exactly the scene you most need; a parallel stream would create a second source of truth with no shared ordering | | Bounded memory | Handled on the **read** side: new `readTextTail(p, maxBytes)` (`src/util/fs.ts`) + `TaskStore.readRecentAgentEvents(taskId, limit, maxBytes=64KiB)` read only a tail window, so memory use is decoupled from total file size. `readTextTail` guarantees the result starts at a whole line (it does not drop a whole line when the cut lands on a newline) | | Reporting robustness | Adapters always report through `makeEmitter`: a no-op when no hook is provided, and it **swallows reporting exceptions** — event reporting is an observability concern and must never affect the task itself | | Exposure | `query_task`'s `eventLimit` (1..50, default 10); `MetaBlockFields.recentEvents` (passed through `metaFromTask(meta, extra)`'s `...extra`) plus a "recent events" section in the text area. Non-reporting adapters return an empty array and every other field matches v0.6.2 | | Scope in this version | Only **codex** and **traework** actually report (four emission points each); zcode / kimicode / qoder and all CLI adapters keep the interface but do not report yet | | Relationship to `note` | `note` is unchanged and remains the progress / audit channel (carrying `progressSummary` / `lastRunSignal`); `recentEvents` filters only the five vocabulary kinds and never mixes `note` in | **Disclosed honestly**: events are an observability capability, not a delivery guarantee — delivery is not guaranteed, and `query_task` reflects only the last persisted event; `file_modification_started` is a heuristic, so read `changedFiles` / `diffstat` from the acceptance report for hard evidence of changes. See [event stream](docs/event-stream.en.md). ### 5.9 Blocking wait primitive (`wait_task`, issue #28; merges `wait_any` as of v0.9.0) Turn-driven callers (a Tianshu agent session) only run within the turn that received a user message and cannot poll on their own — once `run_task` returns a `taskId` immediately, "who wakes the session when the task finishes" is a real gap in the tool surface. This capability carries the wait with **one blocking, read-only call**. | Decision | Implementation and rationale | |---|---| | Stop-point definition | `isWaitSettled(status) = isTerminal(status) \|\| status === "needs_user"` (`src/tasks/task.ts`, single decision point). The moment a task **stops making progress** is the moment to wake the caller: `needs_user` is not terminal but has stopped awaiting a human (it can be resumed by `manage_task(action="continue")` and may re-enter) — without waiting for it, `wait_task` blocks until the timeout and the caller knows nothing about "the task is waiting for a person" | | Wait core | `waitForStops(taskIds, getMeta, {timeoutMs, pollIntervalMs=500, signal})` in `src/tasks/wait.ts`: **pure logic with dependency-injected `getMeta`**, touching neither the filesystem nor TaskManager construction, so it is independently unit-testable; `TaskManager.waitForStops` only wires it (injects `(id) => this.getMeta(id)`, reusing the `waitForStatusWrite` barrier + memory-first + snapshot fallback) | | Read-only, lossless | During the wait it **writes no task state and touches no task body**; a client truncation / connection drop / timeout never affects the task's continued execution — worst case the caller calls again, and `query_task` yields the latest fact after reconnect | | Timeout policy | `timeoutMs` defaults to `WAIT_TASK_TIMEOUT_DEFAULT_MS` (50 s, below the common 60 s client tool timeout to leave serialization/round-trip headroom); explicit cap `WAIT_TASK_TIMEOUT_MAX_MS` (600 s), and values above it are **clamped and disclosed honestly** (never silently rewritten, `clampWaitTimeout`). A timed-out response steers the caller into a call loop (≈50 s per round; long tasks need several calls) | | Interruption awareness | The SDK's `RequestHandlerExtra.signal` (`server.ts` forwards `extra` to the handler) fires on connection close / request cancellation and the wait loop exits immediately without leaking background waits (the SDK's `_onclose` aborts every in-flight handler) | | `wait_task` batch-mode return semantics | Returns the **first task in `taskIds` array order** that has reached a stop point (determinism first; no `finishedAt` sorting); the entry point validates that all ids exist, failing closed with the missing ids listed if any is absent | **Known limits**: the wait is **in-process** — after a server restart the original wait call ends with the connection (the caller re-checks via `query_task` after reconnecting); a single call waits at most 600 s, and longer scenarios rely on repeated calls (lossless). See [wait primitives](docs/wait-task.en.md). --- ## 6. Orchestration: the automatic verify-and-rework loop `TaskOrchestrator` (`src/loop/fix-loop.ts`) is the per-task execution body. ```text Start ├─ Capture the git baseline + freeze/check the visual snapshot │ └─ visual error → needs_attention(verify_failed) + pendingVisualVerification ├─ If resuming a blocked task: re-verify first (pass ends it, no rework, no wasted round) └─ Rework loop (round = meta.roundsUsed, maxRounds = meta.autoFixRounds) ├─ status=running → buildCtx(meta, round, feedback) → hold project lock → runAgentOnce │ runAgentOnce: adapter.run exists → call it; otherwise runChild + parseExit ├─ Branch on the agent result │ needs_user → needs_user (awaits manage_task(action='continue')) │ idle/timeout/cdp loss → needs_attention │ hardFailure → failed(spawn) # infra/auth error, skips verify and rework │ timeout / killed → corresponding terminal state ├─ Verify (runVerifyOnce): roundsUsed = round + 1, record report and change set └─ Termination decision blockingIssues → needs_attention + pendingVisualVerification verdict.passed → succeeded maxRounds > round → fixing, round += 1, write repair plan, continue maxRounds == 0 → failed(verify_failed) + rework hint rounds exhausted → needs_attention ``` **How failure information returns to the agent** — two paths, chosen by agent: | Path | Artifact | Feedback content | |---|---|---| | Codex | `/.zcode/plans/codex-fix-r.md` | Failure evidence extracted from the report, embedded in the fix prompt | | Others (incl. CLI) | `/rework--r.md` | Heading + plan path + `verifySummary` + report path | **Key design point**: `hardFailure` and "verification failure" are strictly distinguished. The former means environment, auth, or startup problems, for which verification and rework are pointless — the task goes straight to a failed terminal state. Only a genuinely finished agent run enters verification and round accounting. **Why `pendingVisualVerification` exists**: a task blocked by a missing approved baseline or a mutated baseline must **re-verify first** when resumed by `rework_task`; passing ends it. This avoids burning an agent round on what is actually a rules problem, not a code problem. --- ## 7. The acceptance engine `AcceptanceEngine.runVerify()` (`src/verify/acceptance.ts`) runs the following stages in order, failing closed on any fatal error: ```text 0. Mutex withVisualLock(task:) then withVisualLock(realpath(projectPath)); a concurrent verification of the same task is rejected outright 1. Baseline capture or reuse a git baseline (verify_task reuses the task's stored baseline) 2. Resolve checks priority: extraChecks > project .tianshu-mcp/acceptance.json > projects.json verify > derived defaults by project type 3. Snapshot freeze #1 freeze the visual rule/baseline digests (**unconditional — independent of visual.enabled**) 4. Git check built-in git-diff-check runs FIRST (git diff --check, over baseline..HEAD and the worktree) 5. Command checks serial or bounded-parallel (verifyConcurrency, default 2, clamped 1..4); in parallel mode each writes verify-.parts/NNN-.log, merged back into the main log in declaration order afterwards 6. Visual checks only when acceptance.json enables visual.enabled, and AFTER the command checks (pages often need the build output first) 7. Code analysis attribute the change set against the baseline → file list / diffstat / signals 8. Change gate a git project with zero net change fails → an extra failing `no-changes` check item 9. Snapshot checks #2/#3 once after the commands and once after the visual stage; drift → VISUAL_INTEGRITY ``` > **Order matters**: the built-in `git-diff-check` runs **before** the configured checks, and the visual stage runs **after** the command checks. > The snapshot freeze and integrity checks are **not** gated by `visual.enabled` — `enabled` only decides whether screenshots/specs/content > judgement actually run. So "visual disabled" does not mean "nothing visual happens": digests are still frozen and compared, which is what > detects edits to the acceptance config or the baselines themselves (`VISUAL_INTEGRITY`). **Derived default checks** (`deriveDefaultChecks`): read `package.json` scripts `typecheck / lint / test / build` and map them to `npm run