Production regression of T1 (7829acc..15e3863). User reports "明明生成
了很多文件,但只有一个" — the filesystem shows 19 markdown deliverables
in ~/Documents/Github/test/{business-plan,operations,ceo-coordination}/
but the DocumentCenter surfaces only one row.
Root cause is structural, predicted in spirit by Karpathy's Round 1
take ("把'认知'和'执行'两件事一起锁是数据结构错"). The T1 rewrite
collapsed two concerns into one nullable field on the run context:
workspacePath = workspace?.localPath ?? null
^^^^^^^^^ ^^^^
lock target observation root
For lockMode='none' (every cognitive C-suite agent post-T1) the
resolver returns workspace=null, so workspacePath was null, so
artifact-projector.ts:242 short-circuited every projection with
`{ kind: "skipped", reason: "no_workspace" }`. The agents kept
writing files via bash (cwd falls back to the company default
workspace at worker/runtime/index.ts:142), but nothing observed
them — the DocumentCenter only saw the one file that was projected
back when the agent still held an exclusive workspace.
Fix: split the two concerns. WorkspaceResolution gains a non-lock
`observationPath` field; the resolver populates it from the company
primary workspace (new `getCompanyObservationWorkspace` query —
primary first, oldest second, null only when company has zero
workspace rows) for lockMode='none' runs, and from `workspace.localPath`
itself for lockMode='exclusive' runs. RunDispatchParams threads the
field through; turn-context's `workspacePath` reads it first.
artifact-projector.ts is unchanged — it just sees a non-null path
again and resumes baselining, diffing, and upserting work_products
rows the way it did pre-T1.
Three new unit tests pin the regression: cognitive role with
workspace ⇒ observationPath set; cognitive role with no workspace ⇒
observationPath null; exclusive ⇒ observationPath equals
workspace.localPath. typecheck green, 543/543 unit tests, 10/10
eval. No fallback, no mock, no silent degradation.
Old resolver: task → agent → project → any company workspace, all
marked exclusive. That fourth step folded every runnable agent in a
multi-agent company onto a single workspace row and serialised the
C-suite (four agents, one exclusive lock, three starved).
New resolver: lockMode='exclusive' is now an opt-in decision inferred
per task. Three rules:
1. task.workspaceId set → exclusive on that workspace (explicit).
2. filesystem signal (task.requiresFilesystem OR agent in a
coding role/engineering department) → exclusive on
agent.workspaceId / project primary; throws if neither exists.
3. otherwise → none / null. Pure cognitive work runs with no
workspace lock and fully in parallel.
Removed:
- company_fallback silent-share path. Filesystem-touching runs
with no legitimate binding now throw WorkspaceConfigurationError
at resolve time so the caller decides recovery rather than the
resolver picking a random shared workspace.
- workspacesMod.getAnyCompanyWorkspace (now dead code).
Invariants now enforced by types:
- lockMode='exclusive' ⇒ workspace is a live WorkspaceRecord.
- lockMode='none' ⇒ workspace is null.
Tests updated to cover the new decision table and the four-non-
coding-agents parallel case that was the original bug.
Old internal-tool-selector attached `file_approval` to any agent with
`reportsTo === null`, and attached both tools through the same
management-keyword predicate chain. That let a VP or director with
org-management authority walk away with `file_approval`, contradicting
the C4 commit narrative that scoped `approve_ceo_strategy` as CEO-only.
Introduce `canFileCeoStrategy(db, companyId, agentId)` as a dedicated
predicate requiring both a CEO-equivalent role/title AND
`reportsTo === null`. Using either half alone is too loose: role alone
lets a demoted CEO with a virtual superior still file strategy,
`reportsTo` alone lets a VP with no manager slip through. The two
conditions together pin the predicate to a genuine top-level CEO.
- `hire_agent` continues to gate on `canRequestHire` (managers hiring
ICs is a legitimate flow and the original "CEO-only" framing in the
C4 commit message was narrower than the production reality).
- `file_approval` now gates on `canFileCeoStrategy`, orthogonal to hire
authority.
- New unit test covers 4 edges: genuine CEO, CEO title with virtual
superior, VP without manager, and plain IC.
Linus review T1 surfaced three real bugs in the B2 Phase 1 shadow
dual-write surface. Fix them in one pass.
1. advanceTaskStatus left task_leases behind on terminal (done /
cancelled / blocked) and "return to queue" (todo / backlog)
transitions. The status->phase trigger advanced tasks.phase but the
lease row lingered as an orphan. dev-mode verifyPhaseConsistency
could see the divergence; production silently drifted. Wrap the
status UPDATE and an explicit DELETE FROM task_leases in a single
transaction so no caller observes the intermediate state.
2. The no-restricted-syntax guard in eslint.config.mjs only matched
literals shaped "UPDATE tasks SET status" -- advanceTaskStatus's
dynamic `UPDATE tasks SET ${timestampSets.join(", ")}` slipped past,
and so did saveTask / recoverStaleLocks (status not first column).
Tighten the selectors to (a) match any status-write within UPDATE
tasks regardless of column order, and (b) flag template literals
whose head ends with `SET ` so dynamic helpers must carry an
explicit eslint-disable with rationale. Five grandfathered sites
now carry matching disables.
3. interruptStaleRuns scoped its task+lease cleanup with
`WHERE finished_at = ?` against the freshly stamped timestamp --
safe in isolation, vulnerable to ISO-millisecond ties with other
already-interrupted runs. Capture the stale run ids before
mutating anything and drive every downstream statement from that
explicit id list. Original semantics preserved; scoping is exact.
Unit tests added in tests/unit/tasks-phase.test.ts cover the
advanceTaskStatus lease cleanup across done / cancelled / blocked /
todo, and pin the in_review case as "lease stays".
Migrate all remaining pass-through call sites (social, skills, budget,
entityCrud, documents, knowledge, automation, wakeup, backup, threads,
settings, snapshot, connectors, workspaces, activity) to their owning
domain modules. `AppDatabase` now carries only three methods:
- createHireRequest (multi-module composite: agents + approvalsMod)
- rollbackConfigRevision (multi-module composite: documents + entityCrud)
- queryAll (raw-SQL escape hatch, @internal)
The vault/env-binding infrastructure previously passed argument-by-
argument into `entityCrud.saveSecret`/`resolveSecretEnv` now binds at
module construction, letting callers invoke `db.entityCrud.saveSecret({...})`
without carrying vault plumbing.
Structural types that narrowed AppDatabase to a bag of flat methods
(`ConnectorModelPolicyDeps` callers, `CapabilityMatchDb`, test mocks in
budget-enforcement / budget-incidents / task-quality-gates /
failure-escalation / task-quality-gates-sweep) are reshaped to the new
namespace. `connectorPolicyDepsFromDb` replaces the old pattern of
handing the whole AppDatabase to `validateConnectorModelSelection`.
A new ESLint rule in eslint.config.mjs blocks new top-level methods on
AppDatabase so the pass-through layer cannot silently grow back.
`AppDatabase.ts` dropped from 193 lines to 58; `AppDatabaseBase` lost
the 270-line trailing pass-through block.
Rewrite ~376 call sites for messages, runs, approvalsMod, and company
domains and delete the corresponding pass-through methods on
AppDatabase / AppDatabaseBase. Structural type references
(ReturnType<HandlerDeps["db"]["getRun"]>) and SweepStaleReviewsDeps-style
mocks are updated to route through the owning sub-module.
Rewrite ~525 call sites from `db.<method>()` to `db.<module>.<method>()`
for tasks and agents domains (including `getAgent`/`findAgent`/`getTask`
which live on the `entityCrud` field). Delete the corresponding
pass-through methods on AppDatabaseBase. Structural types that narrowed
`db` to a bag of flat methods (CapabilityMatchDb, EscalationDeps,
SweepStaleReviewsDeps) are reshaped to match the new namespace. Internal
callback references inside database.ts/database-base.ts now invoke the
owning module directly (e.g. `this.agents.saveAgent`) rather than the
deleted pass-through.
The codemod lives at scripts/codemod/remove-appdatabase-passthrough.mjs
and filters by owning module + optional explicit method list. It is
idempotent and does not re-rewrite already-namespaced calls.
Consolidate the two runtime messaging modules under src/main/messaging/. Storage stays in database/messages.ts — that is the SQL layer and belongs with the other DB modules; messaging/ covers the dispatch/normalize runtime around it.
- message-dispatch.ts -> messaging/dispatch.ts
- message-normalize.ts -> messaging/normalize.ts
- add messaging/index.ts barrel for future consumers (existing call sites import the concrete module path directly)
- 10 call sites updated across src/main and tests
Collects the five post-run diagnostic + escalation modules into src/main/worker/observability/: run_events persistence, stdout/stderr noise filter, credential-shape log redactor, self-wake circuit breaker, and retry-aware failure escalation pipeline. circuit-breaker.ts and failure-escalation.ts remain as standalone files inside the directory — each owns a distinct state machine that deserves its own boundary — rather than collapsing into a single index.ts. handlers/*.ts and tests/unit/*.test.ts updated to point at the new paths.
The operator's mental model: a chat is a room, @X means "X takes the
turn", unmentioned participants keep working. Two separate gaps were
letting 张工程师 answer when the user wrote "@CEO 更新一下进度" inside
their Board↔张工程师 chat:
* `resolveMessageWakeTargets` always woke the DM recipient, regardless
of who the body @-mentioned. CEO woke too, but 张工程师 — the
addressed party — replied first and CEO's busy-queued wake arrived
after the operator had already taken the earlier reply as "the
answer". Explicit mentions now shadow the implicit DM recipient:
with mentions, only the named agents wake; the recipient still sees
the message in their inbox, they just aren't obliged to answer.
No mentions → fall back to the historical "DM wakes the recipient"
contract so every existing flow is untouched.
* `channel-utils` keyed each chat by `sort(fromAgent, toAgent)`. CEO's
reply with `toAgentId=__board__, parentMessageId=<original>` landed
in a fresh Board↔CEO pane — the exact "new session" the operator
complained about. Walk `parentMessageId` back to the root of the
reply chain and use the root's participant pair: every reply in a
thread now collapses into the chat the sender originally opened,
cycle-guarded against malformed parent pointers.
Pinned by a new unit test: DM to CTO with "@CEO 更新一下进度。" wakes
only CEO while keeping the message visible to both CTO's and CEO's
inbox.
A DM addressed to one agent that @-mentions another was silently
dropping the second agent out of the conversation:
* `dispatchAgentMessage` added the mentioned agent to the wake set,
so CEO's heartbeat fired — but
* `listAgentInboxUnread` only surfaced messages where the current
agent was `to_agent_id` or sat in a matching channel/project/
department. Mentions were invisible, so CEO's next turn saw nothing
new and returned silently. From the operator's chair this looked
exactly like "CEO ignored me; 张工程师 answered instead".
* `findMentionedAgentIds` had a latent bug on top: its unique-alias
filter used `ids.length === 1`, but a single agent whose name
matched its role registered itself twice, tripped the filter, and
every `@CEO` in a scaffold with role === name was a no-op. Once
inbox visibility was fixed the bug became load-bearing.
Root-cause fixes:
* New migration v48 adds `agent_message_mentions(message_id, agent_id,
company_id, created_at)` so mention fan-out is a first-class join.
* `sendAgentMessage` now takes the resolved mention set and writes
the row + mentions in a single transaction — a crash between the
two writes can no longer leave a message visible to the recipient
but invisible to the mentioned agent.
* `dispatchAgentMessage` computes the mention set exactly once via
`resolveMentionedAgentIds` and hands the same list to both the DB
write and `resolveMessageWakeTargets`, so persistence and wake
stay in lockstep.
* `listAgentInboxUnread` and `listAgentMessages` gain an `id IN
(select message_id from agent_message_mentions …)` clause so the
mentioned agent's inbox actually contains the message that woke it.
* `findMentionedAgentIds` now stores per-alias agent IDs in a `Set`
and filters on `ids.size === 1`, dropping the self-duplication
bug while preserving the "refuse ambiguous aliases" semantics.
* Company teardown drops `agent_message_mentions` alongside the
existing `agent_message_reads` cleanup so deletes stay clean.
Tests pin the new behaviour: a DM with a `@CEO` mention reaches
CEO's inbox and both agents get woken, while a DM whose recipient
is `__board__` never attempts to wake the human sentinel.
The deliverable check used to compare a run against knowledge entries,
documents, and substantial task comments only, so a research or content
task that produced its output as a markdown file on disk (via
write/edit/bash) stayed pinned in_progress even though the deliverable
was already sitting in `work_products`.
Split the DB query into `collectRunDeliverableSignals`, which returns
the four surfaces (knowledge / document / work product / substantial
comment) explicitly, and push the per-task-type acceptance logic into
`automation-run-completion`. Each task type declares which signal set
counts as a valid deliverable: content tasks now require a document or
an on-disk artifact and will not advance on a knowledge entry alone;
research and general accept any of the four; code and management
preserve their broader surface. Tests updated to cover the new signal
and the content/knowledge rejection case.
The Document Center only read the `documents` table, which is prose
content POSTed via /api/documents. Everything the agents wrote via
`bash` / `write` / `edit` — the bulk of their output on a real
project — landed in `work_products` and never appeared on the page.
Operators saw "0 items" after a full night's run.
Lift work_products into the same UI:
- `listWorkProductsForCompany` returns a newest-first slice with a
500-row cap so the snapshot stays bounded.
- `ProfileSnapshot.workProducts` flows through `useCompanyData` as
`companyWorkProducts`, filtered to the current company.
- `DocumentCenter` renders two labelled sections in order — prose
documents (editable) on top, files & deliverables below. Each
work-product row opens the underlying URL or filesystem path
when clickable, so the agent's bash-generated markdown, PRs,
and artifacts are one click away.
`WorkProductRecord` lifts into `@shared/types/records-extended` so
the renderer, snapshot, and database shells all reference the same
shape. The store-test fixture learns about the new field.
The session_entries supersede pipeline was a design that never
shipped: `SqliteSessionStore.replaceMessages` / `appendCompaction`,
`sessionEntries.replace` / `listAll` / `deleteForSession`, and the
`"compaction" | "summary"` kind variants had zero live callers.
Compaction wires through pi's `transformContext` and leaves the
persisted transcript alone, so the shadow-row machinery was pure
cruft with two tests propping it up.
Rip it out: the store exposes only `loadMessages` and
`appendMessage`, `sessionEntries` exposes only `append` and
`listActive`, and `SessionEntryKind` narrows to `"message"` so new
writers can't re-introduce a dead kind by accident.
`is_superseded` stays on the table — the DB column is harmless
with no writer, and dropping it is a separate schema migration
concern.
`listTopLevelAgentIds` loses its only caller in the previous
commit (company channel now fans out through
`listCompanyBroadcastAgentIds`), so it goes too. Three tests that
validated the removed behaviour are deleted; the one remaining
session-store test still covers the live `append + load` path.
"Company" channel fell through to `listTopLevelAgentIds`, so a
broadcast on the most ambient-sounding channel in the product only
reached agents with `reports_to IS NULL`. The label said company;
the semantics said board-only. The rest of the org never knew a
message existed.
Add `listCompanyBroadcastAgentIds` — returns every non-terminated,
non-paused, non-pending agent for the company — and wire it through
the `company` case in `resolveMessageWakeTargets`. Board-only
fan-outs are still reachable: the `incident` channel keeps the
broader pingable set (paused-by-budget included) and direct DMs to
top-level agents work as before.
The prompt doc catches up to the runtime: company is explicitly
framed as all-hands, with board-only guidance redirected to DMs or
incidents. The message-dispatch unit test asserts the new fan-out.
`init()` ran three implicit data-repair queries on every boot:
`reconcileOrphanedTasks`, `reconcileUnreviewableTasks`, and a
secrets-table migration whose fallback glued legacy rows onto a
synthetic zero UUID when no current company existed. The first
two papered over state-machine leaks at init time — hiding the
real bug and making startup observably mutate production data;
the third silently connected ciphertext to a non-existent company
with no UI visibility and no deletion path.
Root fix: the startup path is now allowed exactly one mutation —
`interruptStaleRuns`, which is crash recovery (running-at-boot =
worker died mid-turn) and cannot be expressed anywhere else.
The dead-letter `in_review` case moves to the transition point:
`tasks-status.guardInReviewHasReviewer` refuses to advance a task
into `in_review` when its assignee is a top-level agent
(`reports_to IS NULL`) and the task is not flagged for user
review. Callers either pre-flag the task (the CEO-completion path
in `automation-run-completion.advanceToInReview` now does this)
or the transition is rejected. No more silent resurrection at
boot; the failure is visible and actionable at source.
The orphan-task reconcile disappears entirely — the `finishRun`
guard already enforces the invariant at the one place a run can
leave the in-progress state, and `recoverStaleLocks` handles
stuck executions at runtime.
The secrets migration now throws when neither
`settings.current_company_id` nor any company row exists, rather
than binding legacy ciphertext to `00000000-0000-0000-0000-000000000000`.
Legacy rows stay on the old table until the operator creates a
real company, at which point the upgrade succeeds deterministically.
Tests rewritten: the reconciliation regression suite now asserts
the transition-time guard and the lone `interruptStaleRuns`
behavior. `resolve-workspace.test.ts` catches up with the
exclusive-by-default lock policy committed earlier.
`pickModelForAgent` used to fall back to the CEO's pair when the
auto-strategy scoring returned no winner, and then silently route
the run on that pair. Three distinct problems rendered invisible by
that fallback: a missing capability row for a deployed model, a
mis-inferred task profile, a misconfigured model pool. Cost
attribution became meaningless — the agent ran on the CEO's model,
not its own tier — and operators had no signal to fix the root
cause.
The picker now returns a `ModelPickResult` tagged union. Success
yields the dispatch-ready assignment; failure yields a
`NO_QUALIFYING_CANDIDATE` reason with a human-readable message
naming the agent, the required domain, and the required tier.
Heartbeat maps the failure to a new `model_pick_failed` skip
reason, logs an activity row so it shows up in the company audit
trail, and defers. Task-runner returns `fail("MODEL_PICK_FAILED",
...)` so the UI surfaces the message directly. `getCeoAssignment`
drops out of `PickModelDeps` entirely — no caller needs a silent
CEO path any more, so the dep surface shrinks and the tests do
too. The test suite gains a regression assertion for the explicit
failure case.
Full-codebase ts-prune + manual audit sweep. Every change below is a pure
deletion (or a 2-line re-wire where a dead function was supposed to be
alive). No scope additions.
Dead wrappers / phantom features
- shouldAutoApproveApproval: zero-value wrapper over shouldAutoApprove.
Four call sites now import shouldAutoApprove from execution-policy
directly.
- evaluateRotation(session, connectorId, thresholdsOverride): the
connectorId arg was never read (underscored), and thresholdsOverride
was only exercised by the test suite — no production caller. Signature
collapses to evaluateRotation(session). DEFAULT_THRESHOLDS /
RotationThresholds exports and their tests go with it.
- installLogRedactor: defensive console wrapper that was never wired
from worker-entry. buildRedactor handles the known threat (env-value
leaks); the pattern-based redactTokens stays for tests and future
composition.
Dead files
- resume-interrupted-tasks.ts: helper function never imported.
- service-graph-orchestration-deps.ts: three factory closures
superseded by the inline closures in create-orchestration-service.
Dead exports
- listConnectorsByAuthKind (shared/connector-catalog)
- getLocalePromptInstruction + whole localization.ts file
- ImportantDocType
- main-side ConnectorAuthKind (duplicated the shared one; not referenced)
- CheckpointEngineService, RunHousekeepingService types
- WorkspaceResolution type
- cmdOrCtrlSymbol
- AgentEventPush, CredentialPayload type aliases
- parseApprovalPayloadJson, parseEnvValue, ProviderCard
- getSOP — buildSOPSection reads the SOPS map directly; the individual
getter had no callers. Deduped to a flat array.
- renderWakeReason in wake-reasons/en.ts: byte-for-byte duplicate of the
one in wake-reasons/index.ts.
Forgotten wiring (not cleanup, but adjacent)
- registerAgentStandupRoute: the GET /api/agents/me/standup endpoint is
advertised in every system prompt and wake-reason doc, but the route
handler was never registered in api-server/index.ts. Agents have been
hitting 404 since Pi Mono. Registered.
Dead DB column
- v44 migration drops runs.context_snapshot_json. Column was added by
v16-v25, never written or read, and v43's deliberate preservation
was an interim measure. Same idempotent table-rebuild pattern as
v41/v43.
Test tree
- Phantom-feature threshold override tests removed (3). The rest of
the rotation suite re-expressed against the real maxRuns=20 floor.
495/495 unit tests pass. typecheck + build clean.
These columns and the messages that plumbed them existed for the CLI-
adapter era — Gemini's resume id and provider-specific session params
rode the run-finished envelope back onto the session row so the next
turn could resume. Pi Mono dispatches every provider through pi-ai and
emits neither value. The worker has been posting literal null on every
finish, finalizeRun has been writing null into task_sessions, and
rotateSession has been carrying null forward.
Remove the wire, the writer, the carry-forward, and the columns:
- v43 migration rebuilds task_sessions without session_params_json /
session_display_id, and runs without session_display_id. Follows
the idempotent "create new, copy, swap, reindex" pattern used by
v41. context_snapshot_json is preserved (also unreferenced today
but outside this cleanup's scope).
- RunJob / EnqueueRunPayload / ChildMessage / worker-types zod
schema no longer carry the fields. finishRun() / createNewSession()
signatures tighten in lockstep.
- rotateSession starts the new session with fresh counters and no
carry-forward.
- turn-context resolveSession returns just the session id.
- TaskSessionRecord / RunRecord lose the dead fields.
498/498 unit tests pass. typecheck + build clean.
The CLI-adapter era `connector.command` field became vestigial once every
provider dispatch went through pi-ai. The schema still required a non-empty
command, so onboarding saveConnector silently rejected every Pi Mono row
(which carries an empty default). Remove the field from the payload chain
(renderer → IPC schema → DB CRUD → run dispatch → worker job), narrow
RunJob.taskId to match EnqueueRunPayload, and drop the dead helper plus
its test. Schema column stays NOT NULL for DB compatibility; seedConnectors
writes a literal empty string so no migration is needed.