edbebe610900c7e83bb66919cc61a3764a6b09a5
286
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
edbebe6109 |
Make document lifecycle fan-out durable and atomic
Pre-v47 `createDocumentWithLifecycle` wrapped each hook in its own try/catch and always returned success, so a failure in any of the downstream hooks (thread event, checkpoint approval, deliverable review, automation dispatch, reviewer wake) left a live document row behind with a silently incomplete lifecycle. Two invariants now hold: - DB-writing hooks (thread integration, checkpoint approval, deliverable review) run inside the same `BEGIN IMMEDIATE` transaction as `saveDocument`. A throw rolls back the document row along with every partial write, so an operator never sees a persisted document with a missing thread or skipped approval. - Cross-process side effects (automation dispatch, reviewer wake) are enqueued as `document_lifecycle_events` rows inside the same transaction and processed post-commit. Success stamps `status = 'succeeded'`; failure stamps `status = 'failed'` with `last_error`, so partial lifecycle state is observable via `listFailedForCompany` instead of disappearing into the log tail. Migration v47 installs the ledger; `buildDocumentLifecycleEventMethods` exposes the enqueue/running/succeeded/failed transitions; the lifecycle service drives both. |
||
|
|
a36884360a |
Key inbound correlation by messageId with monotonic pi-timestamps
`registerInboundInjection` used to index correlations by `Date.parse(record.createdAt)` — a millisecond-precision wall-clock value. Two inbox rows that landed in the same millisecond collapsed onto one Map slot, and the thread-event writer lost one peer's identity on `message_end`. Identity now lives on `record.id`. `projectInboundMessage` allocates a monotonic `piTimestamp` per run (base = `Date.parse(createdAt)`, bumped to `max(base, lastAllocated + 1)` so it is always unique within the run) and stamps that value onto the projected pi message. The correlation registry keeps `byMessageId` as the primary index and `byPiTimestamp` as a reverse lookup so the `message_end` callback — which only surfaces the pi-message shape — still resolves back to the author without depending on wall-clock precision. The runtime message injector now defers projection to `RuntimeBridge.projectInboundMessage` so the steered message, the correlation entry, and the allocated timestamp stay in lockstep. |
||
|
|
cb0ac485da |
Close inbox projection under one transaction
The old `prepareRun` projection drifted: append-to-shadow, thread-event, and `markAgentInboxDelivered` ran as three independent writes, so a crash between the append and the delivered mark replayed the same inbox row on the next run and double-populated the transcript. v46 installs `agent_message_projections` keyed by (message_id, session_id) as the idempotent gate. The projection closure now wraps the ledger claim, the shadow append, the thread-event write, and the delivered-mark inside one `BEGIN IMMEDIATE` transaction (exposed via `AppDatabase.withTransaction`). `claimProjection` returns false on a replay, so the whole closure short-circuits — no double transcript, no double thread event. |
||
|
|
d9b35d73ab |
Pin work_products identity to (company, workspace, path)
The natural key used to be (companyId, provider, externalId), so two workspaces with the same workspace-relative path collided onto one row. Provenance was also frozen at the first write: `created_by_run_id` never refreshed, so a run that edited a pre-existing file never qualified as a deliverable and the DocumentCenter "open" link could still jump into the first workspace after a repo move. Migration v45 adds `workspace_id` + `last_touched_by_run_id`, backfills `workspace_id` via the task/run join, seeds `last_touched_by_run_id = created_by_run_id`, and installs a partial unique index on `(company_id, workspace_id, provider, external_id)` scoped to rows that have a workspace. The artifact projector now records `workspace_id` on insert and refreshes `last_touched_by_run_id` on every upsert. The deliverable signal query reads either provenance column so a run that only edited a pre-existing file still counts. `resolveWorkProductTarget` prefers the stored `workspace_id` and falls back to the task/run join for legacy NULL rows. |
||
|
|
686140ae55 |
Route every document save through one lifecycle pipeline
Desktop IPC saves used to fire only automation and the reviewer wake, while HTTP API saves fired automation, thread integration, checkpoint engine, deliverable-review approval, and the reviewer wake. Two divergent paths produced two different lifecycle states for the same document, and a future fix to either branch would miss the other. Introduce `document-lifecycle.ts` as the single owner of document creation side effects: save, derive task binding from the originating run, fan out to automation/thread/checkpoint, wake the reviewer, then raise a deliverable-review approval when the document qualifies. The HTTP route and the IPC handler both call the pipeline — the API server's `onDocumentCreated` callback is replaced with a `createDocument` hook that delegates to the pipeline. Wire `checkpointEngine` through `bootstrap-application` → `main-runtime` → `main-runtime-support` so the IPC handler bag carries both thread integration and the checkpoint engine required by the pipeline. The document-run-provenance integration test harness is updated to pass a `createDocument` implementation backed by the same pipeline the production path uses. |
||
|
|
ff9a52324e |
Route work-product clicks through a resolver IPC
Filesystem work products store `external_id` as a workspace-relative
path so artifacts survive a workspace-root move, but the renderer was
handing that relative fragment straight to `shell.openPath`, which
needs an absolute managed path. Clicks flickered into a failed open.
Add a dedicated `openWorkProduct({ companyId, id })` IPC backed by
`resolveWorkProductTarget` on the DB layer. The resolver joins the
work-product row back to its originating task → workspace (or run →
workspace for taskless runs), composes an absolute path, and rejects
any relative fragment that would escape the workspace root. The
renderer now passes the work product's id — path resolution stays on
the main process where the managed-path policy already lives.
|
||
|
|
1a0fdc6f31 |
Collapse the GET /api/agents/me/standup duplicate to one module
Two registrations of the same route lived in the codebase: one inline in routes-agents.ts that filtered three full company-wide SELECTs in memory, and the dedicated routes-agents-standup.ts that uses targeted standup-shaped DB queries. The router keeps the first match so the inline copy was serving every request and the focused module was dead code — any future fix to standup would have silently missed production. Remove the inline standup handler and its three companion helpers (`listAgentsForStandup`, `listTasksForStandup`, `listApprovalsForStandup`) plus the unused `AgentStandupRow` type. The dedicated standup module is now the single source of truth. |
||
|
|
efb4bddab6 |
Count work_products toward a run's deliverable judgment
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. |
||
|
|
23a19a8180 |
Promote artifact registration to a primary run outcome
Per-tool `afterToolCall` projection stays the primary write path but is now backed by an end-of-run reconciliation sweep on `RuntimeBridge`. `reconcileWorkspaceArtifacts` re-scans the workspace via `git status` before the run's resources are released, idempotently upserting any file the per-tool hook skipped — the exact gap that was leaving the DocumentCenter empty while the disk filled up. Projection failures stop being swallowed silently: the hook now logs a tool-level summary at INFO and throws are routed to ERROR so they are visible in the desktop log tail. The reconciliation sweep surfaces its own failures the same way, so an operator can tell the difference between "no files produced" and "files produced but registration broken". |
||
|
|
1b8e6d384a |
Write thread events for taskless communication runs
recordThreadEvent used to drop the event whenever `envelope.taskId` was null, so communication wakes produced nothing in the thread view even while the agent actively replied to peers. Taskless runs now write into a per-agent general thread (`ensureAgentCommunicationThread`), so the UI surfaces peer conversations alongside task activity. Peer-authored user turns inside the transcript used to be labelled with the recipient's id — pi erases the sender once the message is projected. A new `inboundCorrelations` map on `RuntimeBridge` pins each pi-message timestamp to its source `agent_messages` row at injection time, so the thread-event writer can recover the real author id and `senderKind` on `message_end`. prepareRun projections (no `message_end` fires for priorMessages) write their thread event directly as the projection lands so they are not silently lost. |
||
|
|
271c6cfec8 |
Bind communication wakes to concrete inbox rows
Replace the worker's generic `"Continue."` default with a dispatch-time `initialUserTurn` that is resolved per trigger. Message and peer-request wakes now enumerate the actual unread messages (id, sender, channel, subject, reply-to) so the agent's first user turn carries the entities it is expected to act on, instead of idling into a status summary. Drop the system-prompt `heartbeatFallbackSection`: its "respond with a brief status summary" line fought the wake-reason block above it and was the main reason communication wakes degraded into self-narration. The wake-reason section plus the bound initial user turn now cover every case that section was carrying. |
||
|
|
e917c614c9 |
Gate inbox delivery on worker-side steer ACK
Replace the fire-and-forget steer with a correlated ACK round-trip so main only marks an inbox row delivered once the worker confirms the message landed on the live Agent. Steer against a run that is still in the `preparing` window or has already started `closing` now falls back to the wake + inbox projection path instead of being silently dropped. Worker publishes `run-agent-state` transitions at Agent construction and teardown; main tracks these on `ActiveRunContext.state` and refuses to steer runs that are not `agent_active`. A rejected or timed-out ACK triggers `wakeAgentIfPossible` so the next `prepareRun` replays the row. |
||
|
|
22ba7a9004 |
feat(documents): surface filesystem work-products in Document Center
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.
|
||
|
|
734a2b04e0 |
fix(runtime): narrow beforeToolCall gate to operational approvals
Previous Task 2 pass gated every destructive tool on any blocking approval or any open wait-state for the agent. That was too coarse: a pending deliverable_review or approve_ceo_strategy (both decision- type approvals, waiting for a human to respond to completed or proposed work) froze the agent's entire toolchain — including the `bash` call the agent uses to POST /api/messages. Result: CEO talked, employees woke on heartbeat, tried to reply, the message POST was blocked as a "destructive tool", the run ended silent. Every reply collapsed to an empty heartbeat. Two corrections land together: 1. Approval gate narrows to the five approval types that actually gate EXECUTION — `dangerous_command`, `secret_access`, `production_deploy`, `social_post`, `budget_override`. Review / decision types are ignored at the tool layer; they remain visible in the board UI and still block via the heartbeat scheduler when the execution mode demands it. 2. Wait-state check leaves the hook entirely. The heartbeat scheduler (`checkpoint-engine.shouldPauseHeartbeat`) is the single owner of "agent should pause because a thread is waiting" — mirroring the rule at tool level produced the same company-wide freeze for any reply on any thread. Heartbeat is the right scope; tools are not. The destructive-tool set and the operational-approval set are both named, readonly, and centralised so the next addition reads like a whitelist instead of a patch. |
||
|
|
5e6e929779 |
refactor: prune session-store and agent-list dead code
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. |
||
|
|
6508e75a62 |
docs: align README and prompt docs with the real runtime semantics
README described target-state features that the previous migration left half-implemented. Now that the runtime actually matches the promise, the docs can stop overselling: agent messaging is described as the real live-steer + inbox flow, approvals as the beforeToolCall gate that actually blocks destructive tools, sessions as the transformContext-driven compaction plus rotation. The architecture bullet list grows to name the seams that landed across this migration — the pi contract wiring, the approval gate, the workspace lock model, and the stripped startup mutations — so the README reads like documentation rather than roadmap. The filesystem prompt doc (`files.en.md`) adds the missing preamble: every bash/write/edit succeeds into work_products automatically, and the API endpoints below are only needed for non-filesystem deliverables. Agents were routinely posting duplicate work products because nothing told them the projection already ran. |
||
|
|
d47e637a45 |
fix(messages): company channel fans out to every active agent
"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. |
||
|
|
0a40c55b3c |
fix(thread-panel): wire the scoped reply composer to real participants
The thread-panel composer was pinned to `channel="direct"` with no
`directParticipantIds` and no `replyTo`. Clicking reply in a human
review thread sent a message with no recipient and the send button
stayed disabled — a silent dead-end for the entire Co-Founder
review UX.
The fix exposes the thread's participant list to the renderer:
- `listThreadParticipantsSchema` and the matching IPC channel
(`agent-company:list-thread-participants`) return the rows from
`conversation_participants` for a given thread.
- `threadIntegration.onTaskCreated` / `onApprovalCreated` already
enrol the owning agent and the virtual user on thread creation,
so the list is populated without any backfill.
- `ThreadPanel` loads participants alongside events and forwards
the agent ids to `MessageComposer.directParticipantIds`. The
composer's existing `directRecipientId` memo picks the first
non-board agent, so replies now target the correct agent — or
surface the "no recipient" notice when the thread has none.
The Board/user member is deliberately excluded from the list the
composer consumes: the composer wants an agent to route to. All
other wiring (wait-state precision, threadId) stays untouched.
|
||
|
|
e53ac36176 |
feat(session): auto-compact long transcripts via transformContext
pi's `transformContext` hook fired on every LLM call but returned the transcript unchanged, so a long-running agent paid per-token for its entire history on every turn. The README promised "sessions persist + auto-compact" but the main loop had no compaction wired in at all — only rotation at the long-cycle boundary. `context-compactor` closes that gap. A pure, provider-free function estimates tokens (~4 chars/token), short-circuits below a 120K threshold, and on overflow keeps the last 20 messages verbatim while collapsing older ones into a single "context summary" user turn. The summary preserves the original task and the first twelve assistant highlights so the agent has continuity without forwarding every historical turn. Compaction is non-destructive: the Agent's in-memory transcript and the SQLite session store still hold the full history, feeding UI timelines and audit. Only the LLM payload shrinks. Rotation remains the long-cycle boundary (separate commit) — compaction is what keeps each individual LLM call within budget. |
||
|
|
7f23f40e87 |
fix(db): strip implicit data mutations from startup
`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. |
||
|
|
cdf783c0b8 |
fix(dispatch): fail explicitly when no enabled model qualifies
`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.
|
||
|
|
76c2f085b6 |
fix(session): evaluate rotation against post-accumulation totals
`accumulateAndRotateSession` called `evaluateRotation` on the pre-accumulation `session` object, then re-read the row only inside the shouldRotate branch. A run that tipped the token threshold on its own usage would read "below threshold" on this cycle and wait a full additional run before rotating — long enough for the transcript to keep growing past the point where rotation was supposed to protect the context window. Read once after accumulation, evaluate against the fresh totals, rotate from the same object. No new queries, no new branches — the failure mode is just an off-by-one-run bug in the original read-eval-rotate sequence. |
||
|
|
4842a9a2e2 |
fix(workspace): hold every resolved workspace exclusively
agent-, project-, and company-fallback workspaces resolved to `shared`, so four agents sharing one repo wrote concurrently and corrupted both the working tree and git's own index state before the per-file `index.lock` even got a chance to kick in. The previous comment justified this as "the normal case", but pi's `createCodingTools()` hands every agent bash/write/edit — there is no read-only category left where shared is safe. This promotes every resolved workspace to `exclusive`. Contention is still handled at the one place it must be: `run-dispatch.ts` synchronously claims the workspace lock, and `finishRun`'s never-started branch rolls the task back to `todo` on a lost race. The "shared mode prevents deadlocks" story was really "shared mode prevents one specific bug in the checkout rollback path that has since been fixed"; the right invariant is single writer per repo, enforced by the DB lock. Concurrent heartbeats for the same workspace now queue through the wakeup table on the next cycle rather than racing the filesystem. |
||
|
|
34814b4dfd |
feat(artifacts): project bash filesystem changes into work_products
The artifact projector only registered write/edit. Everything bash produced — build artifacts, files written via shell redirection, git commits, generated code — stayed invisible to the desktop UI and the audit layer. Filesystem truth and UI truth were systematically out of sync. This lifts bash onto the same filesystem artifact protocol as write/edit. When a bash tool call succeeds in a git workspace, the projector runs `git status --porcelain=v1 -z --untracked-files=all` and upserts a work_products row for every modified/untracked entry, reusing the existing `(companyId, provider='filesystem', externalId)` identity so repeated edits never duplicate. Non-git workspaces fall through with an explicit "not_a_git_repo" skip reason — there is no authoritative baseline there to diff against, and silently half-registering would be worse than skipping. A 50-file cap per invocation bounds the scan so a runaway rebuild cannot flood the table; anything past the cap is logged as a skip. Rename entries (emitted as `R new\0old`) advance the iterator past the old-path entry so deletions of the rename source are not double-registered. The projector now always returns an array so `write`/`edit` (one path) and `bash` (many paths) share one shape, and the hook handler can log uniform outcomes. .gitignore gains `AgentCompany-Rust/` — the separate Rust rewrite tree lives in the same repo root but is not part of the desktop app build and should not drift into feature commits. |
||
|
|
5ee3d1394a |
feat(messaging): route messages to live runs via pi steer
Incoming messages (human → agent, agent → agent, channel broadcast) persisted to the inbox and were only projected into the recipient's transcript on the next `prepareRun`. When the recipient was already running, every message waited for the next wake — a Slack-like system operating at heartbeat cadence, not in real time. `RuntimeMessageInjector` closes the gap: it looks up the recipient's active run through `RuntimeBridge.findActiveRunForAgent`, projects the persisted `AgentMessageRecord` via the existing inbox-projection helper, and posts a `steer-run` ParentMessage into the worker. The worker's runner enqueues the projection on the Agent's steering queue; pi drains it at the next turn boundary and emits the usual `message_end` → `persistMessage` sequence, so the session store shadow is preserved without double-writing. `dispatchAgentMessage` gains an optional `injectRuntimeMessage` hook used by every API-side dispatch. Injection succeeds → we mark the inbox row delivered and skip `wakeAgentIfPossible`. Injection fails (no active run) → the legacy inbox + wake path still fires, so sleeping agents continue to receive their messages on the next turn. The injector is owned by the service graph alongside `workerService` and `piRuntimeRef`, which gives it both the worker postMessage channel and the RuntimeBridge active-run map without additional coupling. Internal dispatch call sites (sweepStaleReviews, automation actions) leave the hook unset; those system messages target idle agents by design and do not need the runtime path. |
||
|
|
795008f56b |
fix(runtime): enforce approval + wait-state gate in beforeToolCall
Approval creation already flowed through the API server, but pi's `bash` / `write` / `edit` tools never traversed that layer, so the gate was purely advisory: the agent kept mutating the workspace while the board tried to hold it. The fix: policy runs at the one place every destructive tool invocation must pass, pi's `beforeToolCall`. `checkApprovalBlock` fails closed on lookup errors — an unreadable approvals table is a safety incident, not a licence to execute. It re-reads `getBlockingApproval` on every call so an approval created mid-run takes effect on the next tool without any cache. `checkWaitStateBlock` rides the unified `hasUnresolvedWaitStateForAgent` query so task, approval, and freeform thread wait states all gate the same tools. Read-only tools are untouched; only bash/write/edit are gated because gating observation would slow investigation without adding safety. The destructive-tool set is promoted to a named predicate so the Task 5 artifact projector can share the same definition when it extends to bash outputs. |
||
|
|
6a37f41dfd |
fix(threads): unify wait-state ownership across thread types
`hasUnresolvedWaitStatesForAgent` only joined through tasks, so approval-only threads and ad-hoc threads where the agent is the requesting party or an explicit participant were invisible. Result: the heartbeat scheduler silently resumed agents that the board had asked to wait on, Co-Founder approval chains deadlocked on the agent side, and any new gate that relies on the query inherits the same hole. Root-fix the query at the SQL layer. `hasUnresolvedWaitStateForAgent` now returns true when any of the three ownership predicates hold: - task thread whose task.assignee_agent_id = agent - approval thread whose approval.requested_by_agent_id = agent - any thread on which the agent is a recorded participant Each branch is an EXISTS subquery so the query short-circuits on first match. The thread-integration helper delegates here so the heartbeat scheduler and the `beforeToolCall` gate share one source of truth rather than reimplementing half-policies in parallel. |
||
|
|
e1693899a2 |
feat(runtime): wire pi-agent-core hooks end-to-end
The worker previously constructed `new Agent({systemPrompt, model, tools,
messages, streamFn})` and stopped there — transformContext,
beforeToolCall, afterToolCall, sessionId, thinkingLevel, and steer/
followUp were all dark. Approval enforcement, artifact projection,
auto-compaction, and mid-run steering all had to reach the agent
through other, non-canonical channels (event observers, prompt-block
injection at prepareRun, API-layer gates). That left the whole pi
contract at one-tenth utilisation and forced business logic into the
wrong seams.
This change rebuilds the contract assembly layer:
- `pi-agent-factory` builds the Agent with the full option surface:
sessionId (taskSessionId), thinkingLevel (mapped from
agent.metadataJson), and three RPC-proxied hooks.
- Each hook shuttles a thin payload over the existing worker RPC
channel into `RuntimeBridge`, which routes to `hook-handlers`:
* beforeToolCall → approval-gate policy (blocks destructive
tools while a pending approval is outstanding).
* afterToolCall → filesystem artifact projection, previously
nested in the tool_execution_end event observer.
* transformContext → pass-through today; compaction wires in
next commit.
- ParentMessage gains `steer-run` / `followup-run`; `WorkerRunner`
keeps a runId→Agent registry so main can inject mid-run messages
into the active pi loop.
`worker-types` extends the rpc-request method enum; `runtime-bridge`
loses the per-event artifact projector and gains a `findActiveRunForAgent`
lookup used by the upcoming inbox → steer path. No public main API
changes yet — the wiring is the foundation for the next five commits.
|
||
|
|
f041336721 |
refactor: nuke dead exports, dead files, dead DB column, dead zero-value wrappers
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.
|
||
|
|
e0e43d6127 |
refactor(session): nuke dead sessionParams / sessionDisplayId pipeline (v43)
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.
|
||
|
|
88a1f8b334 |
refactor(connectors): drop dead CLI command field after pi-mono migration
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. |
||
|
|
1261c2431f |
refactor(heartbeat): close the silent-skip loop; durable skip reason
Symptom: scheduler logs `[heartbeat] Waking agent X (interval: 120s)`
every 15 seconds for three ICs, but no `[heartbeat] Starting taskless
heartbeat run` ever follows. Agents are wedged, UI shows nothing, and
the operator has no way to see why.
The loop had three compounding flaws:
1. `startAgentHeartbeatRun` returned `string | null`. Every gate
rejection was a silent `return null` — most were `logger.debug`,
which prod log levels filter out. Neither the scheduler nor the
renderer could tell the difference between "ran fine" and
"blocked by budget / no tasks / pending approval." The function
name promised "run or not" and the enum of reasons lived in
comments, not in the type.
2. The scheduler logged `[heartbeat] Waking agent ${name}` BEFORE
calling start, so even pure no-ops spammed the log. The "wake"
wasn't actually a wake; it was an attempt.
3. `markHeartbeatAt` is only called when a run starts. When start
returns null, `lastHeartbeatAt` stays frozen. On the next 15s
tick, `now - lastHeartbeatAt >= intervalMs` is still true, and
the scheduler fires again. The loop was a fixed-point at "skip →
log → skip → log" running 8 times per minute until something
external changed.
Root fix (data structure + visibility):
- `startAgentHeartbeatRun` now returns a discriminated union:
`{ status: "started"; runId } | { status: "skipped"; reason }`,
with `HeartbeatSkipReason` closed over 16 named reasons. Every
exit path carries its reason. The type tells operators what the
function can actually do; the enum tells them what it did.
- Scheduler holds a per-agent `attemptedAtByAgent` map. The interval
check uses `max(lastHeartbeatAt, lastAttemptedAt)`, so a streak of
skips still respects the heartbeat interval. Failed attempts cool
down the same as successful runs.
- On `skipped`, the scheduler persists `{reason, at}` into
`agents.runtime_state_json` under `heartbeatSkip`. On `started`,
it clears the field. The UI can read per-agent skip state from
the snapshot without a new IPC or table; operators finally see
"agent waiting: no_actionable_tasks" instead of a blank screen.
- The `Waking agent` log is gone. Its replacement prints once per
actual skip, with the reason inline (`[heartbeat] X not ready:
no_actionable_tasks`), and the existing `Starting taskless
heartbeat run for X` keeps marking the happy path.
`wakeAgentIfPossible` keeps its external `string | null` contract;
only the internal call site adapts. No caller outside of
`heartbeat.ts` / `scheduler-heartbeat.ts` / `scheduler.ts` needed
changes.
File sizes: heartbeat.ts 290, scheduler-heartbeat.ts 133. All under
the 400 cap. Tests: 603/603 green. Typecheck clean.
|
||
|
|
d75bfd5851 |
refactor(model-pool): scope disabled-models by company; cascade on delete (v42)
The per-connector "enabled/disabled model" settings lived under a
global key in the settings key-value table
(`disabled_models:${connectorId}`). No company dimension. Deleting a
company left the pool choices behind, and creating a new company
silently inherited them — the visible symptom was "onboarding's team-
model configuration survives company deletion and pollutes the next
company's CEO model list."
Root fix, not a cleanup hook: the storage becomes a real relational
table with `(company_id, connector_id)` primary key and an
`ON DELETE CASCADE` FK from `companies`. Company deletion wipes pool
config by construction; no cleanup step in `deleteCompany()` can
drift.
Schema (migration v42):
- CREATE TABLE connector_model_pool_settings (company_id, connector_id,
disabled_models_json, updated_at), PK (company_id, connector_id),
FK companies(id) ON DELETE CASCADE.
- Drop every `settings.disabled_models:*` key — these were the bug's
residue; there is no right company to attribute them to, so the
migration clears them. Users re-select on next onboarding.
API surgery — every reader/writer now takes companyId:
- db.getDisabledModels(companyId, connectorId)
- db.setDisabledModels(companyId, connectorId, models)
- db.hasModelPoolSettings(companyId, connectorId)
- connector-model-policy: getConnectorModelPolicy(deps, companyId,
connectorId); validateConnectorModelSelection(deps, companyId,
connectorId, model)
- model-dispatch: listEnabledModels(companyId, deps);
pickModelForAgent resolves companyId from agent.companyId
- orchestration heartbeat/task-runner/turn-context/run-dispatch pass
the agent's or run's companyId through to the policy deps
- IPC schema: connectorDisabledModelsSchema gains companyId; new
connectorModelPoolQuerySchema pairs companyId+connectorId for reads
Onboarding becomes commit-on-launch:
- useConnectorModelSelection no longer persists via IPC during
onboarding. Toggles mutate local state; it exposes `poolSelections`
to the wizard.
- bootstrapOnboardingSchema gains connectorPoolSelections. The
onboarding service saves the company first, then persists each
per-connector disabled list scoped to the new company id — CEO
model validation runs against the company's freshly-persisted pool,
not some stale global.
- IPC setConnectorDisabledModels now requires companyId; the only
consumer was onboarding, which no longer calls it at all.
- Main-side listConnectorModels returns the raw provider catalog
(no pool filtering), since that IPC is used from onboarding where
no company exists yet; pool filtering happens at dispatch time.
saveConnector IPC drops the per-company pool validation — connector
config is a global hint, company-scoped enforcement belongs at run
dispatch where the company is known.
Tests: 603/603 green. Typecheck clean. Integration tests rewritten
to set the pool via connectorPoolSelections on bootstrap input, not
a pre-bootstrap global setDisabledModels call.
|
||
|
|
2ace9183b0 |
refactor(session): drop dead task-scoped session columns (v41)
Sessions have been agent-scoped since v34, but the schema kept four
dead task-scoped columns that every session code path had to pretend
were meaningful:
- tasks.active_session_id (mirrored agents.session_id)
- tasks.session_json (superseded by task_sessions.session_params_json)
- tasks.session_display_id (superseded by task_sessions.session_display_id)
- task_sessions.task_id (always empty-string on new rows)
v41 drops all four via SQLite table rebuild — the only safe path when
multiple columns need to go in one step and indexes / FK directions
need to be preserved. `PRAGMA foreign_keys = OFF` spans the swap
window so cascade-delete does not fire mid-rebuild. The migration is
idempotent and also backfills the optional task columns
(task_type / requires_user_review / retry_count / last_failure_at /
started_at / completed_at / cancelled_at / checkout_run_id /
execution_locked_at) before rebuilding so it works whether or not
`ensureOptionalColumns` has run yet on this install.
Along with the schema change, every code path that read or wrote
those columns has been rewired to the agent-scoped equivalents:
- task-sessions-crud.ts: deleted updateTaskSession,
getTaskSession, getActiveTaskSession, updateTasksActiveSessionForAgent;
added updateSessionParams(sessionId, ...) for the worker's
finalizer. ensureAgentSession and createNewSession no longer touch
task_id.
- database-base.ts: facade drops the now-unused pass-throughs.
- snapshot.ts / task-queries.ts / tasks-status.ts / entity-crud.ts:
SELECT projections no longer pull session_json / session_display_id
/ active_session_id from tasks.
- tasks-crud.ts (TaskRow) and shared/types/records-core.ts
(TaskRecord): sessionJson / sessionDisplayId fields removed.
- TaskSessionRecord: taskId field removed.
- turn-context.ts::resolveSession: no more
updateTasksActiveSessionForAgent mirror; sessionParams read directly
from the current session via getSessionById.
- run-finished-helpers.ts::finalizeRun: stamps sessionParams via
updateSessionParams on the agent's current session, resolved through
agents.session_id.
- session-management-service.ts: rotateSession stops calling
updateTasksActiveSessionForAgent.
Test suite updated to use the new session-id API. Existing tests used
`getActiveTaskSession(taskId)` and `createNewSession({ taskId, ...})`
as probes; they now use `getSessionById(sessionId)` and the taskId-less
createNewSession input.
Tests: 603/603 green. Typecheck clean. Migration tested on fresh DB
(runs v1→v41 cleanly) and idempotent re-apply (detects absent dead
columns, skips).
|
||
|
|
6abc1a823f |
refactor(inbox): derive from snapshot; delete getInbox IPC chain
Sibling refactor to the sidebar-badges cleanup. The inbox aggregates five item categories (pending approvals, failed runs, budget warnings, blocked/review tasks, unread company/incident/urgent messages) — every row is already in `ProfileSnapshot`. The server-side `getInbox` aggregation (180 lines of SQL + projection) was a redundant second path. New: `src/shared/inbox-items.ts::deriveInboxItems(snapshot, companyId)` produces the same InboxItem[] shape with the same severity ordering and 24h failed-run window. Pure function, no IPC, no state. Deleted: - database/company-crud.ts::getInbox (180-line aggregation) - database-base.ts::getInbox pass-through - ipc-core-read-handlers.ts: handler - preload/index.ts: bridge binding - shared/contracts/channels.ts: channel name - shared/contracts/desktop-api/companies.ts: API signature - state/slices/company.ts: `inboxItems` field, `setInboxItems` action - useDataLoader.ts: both getInbox call sites (initial snapshot load and section-change effect) - useCompanyController.ts: inbox reset on company switch OperationsSections now computes `inboxItems` via `useMemo` off the current snapshot + company id. The snapshot is already company-scoped at the main/database/snapshot.ts layer when `currentCompanyId` is non-null; the derivation re-filters defensively so the function works correctly even for unscoped snapshots. Tests: 603/603 green. No new tests added — the derivation is a pure function whose shape is exercised end-to-end by the existing Inbox component integration. |
||
|
|
487f2456ff |
refactor(badges): derive from snapshot; delete getSidebarBadges IPC chain
Badges were fetched via a dedicated IPC that aggregated counts on the server, while the same data lived in ProfileSnapshot on the client. Two paths to the same answer drift whenever one side mutates between refreshes — the visible symptom being "badge = 1 unread" with an empty inbox after a message gets marked read in another screen. Delete the whole server path. Sidebar badges are now derived on the client from the snapshot via `deriveSidebarBadges(snapshot, companyId)` in `src/shared/sidebar-badges.ts`. One source of truth; drift is physically impossible. Deletions: - database/company-settings.ts::getSidebarBadges (120-line SQL aggregation) - database-base.ts::getSidebarBadges pass-through - ipc-core-read-handlers.ts: handler - preload/index.ts: bridge binding - shared/contracts/channels.ts: channel name - shared/contracts/desktop-api/companies.ts: API signature - shared/contracts/schemas-badges.ts::sidebarBadgesSchema Zod schema - renderer/state/slices/company.ts: `badges` field, `setBadges`, `refreshBadges` - useDataLoader: both getSidebarBadges call sites - useCompanyController: badge reset on company switch - useDesktopEvents: refreshBadges call on granular patches - tests/unit/store.test.ts: refreshBadges test (deleted code) The snapshot's agentMessages already carries board-reader read state via boardReadReceiptMap (`snapshot.ts:39-47` has always LEFT JOIN'd agent_message_reads with agent_id = BOARD_READER_ID). So derivation needs no schema change — the infrastructure was in place, just unused. fix(communication/MessageList): render orphaned-reply messages at the top level The thread-flattening used `filteredMessages.filter(m => !m.parentMessageId)` to pick top-level messages. When an agent replies to a company/incident post with a DM (the CEO prompt explicitly instructs this in wake-reasons/en.ts:119), the DM lands in the direct channel with parentMessageId pointing to a message that lives in another channel. That parent is not in `filteredMessages` for the direct channel, so the reply had no parent to nest under AND was excluded from top-level. The channel rendered as a blank pane even though exactly one message matched — the "clicked Dr. X ↔ Dr. Y, got empty" symptom. Fix: a message is top-level when it either has no parent OR its parent is outside the current filtered view. Replies with in-view parents still nest normally. |
||
|
|
ff5e22092a |
refactor(session): agent-scope rotation + accumulation; auto-register file artifacts
Sessions are already agent-scoped post-v34, but the code kept routing
rotation / accumulation / handoff through the task indirection. Heartbeat
runs (no task) silently skipped all three, letting taskless transcripts
grow past the model's context window — the 7M-token explosion.
- DB adds getSessionById(sessionId) — direct lookup bypassing
tasks.active_session_id
- session-management-service: generateHandoff(session) is now agent-scoped,
walking recent runs across tasks + heartbeats; getHandoffForAgent(agentId)
is the canonical lookup, getHandoffContext(taskId) is a thin shim for
callers that still hold a task id
- turn-context.ts::resolveSession drops the `if (!task)` early return:
rotation evaluation runs on the agent session unconditionally
- run-finished-helpers.ts::accumulateAndRotateSession drops the `!task`
gate: tokens accumulate and rotation fires for every run, keyed on the
agent session, not the task
- Rotation evaluator contract: evaluatePreRunSessionRotation(agentId,
connectorId) → { rotated, handoff, sessionId }; old (taskId, connectorId)
signature removed
IPC contract tightening: ipc-support::emitEvent becomes a best-effort
sink. "Render frame was disposed" during renderer reload is a lifecycle
signal, not a logic error; silently drop it instead of letting it abort
downstream event emissions.
Artifact projection: pi's write/edit tool results now upsert a
`work_products` row via a new agent-runtime/artifact-projector.ts. No
more agents writing 54 files into the workspace that the UI never sees.
Migration v40 relaxes work_products.task_id to nullable so heartbeat
artifacts can register too.
File size: split agent-event-projection.ts out of runtime-bridge.ts to
keep the bridge under the 400-line cap after adding the projector hook.
Tests: 604 unit + integration green; generateHandoff callsites updated
for the single-argument signature; artifact-projector has pure-function
coverage via the projector module's own shape.
|
||
|
|
c71d033b0c |
refactor: prune dead IPC surface and orphaned modules
Drop code that nothing consumed. Verified by full renderer+shared grep:
every removed channel had zero callers via window.agentCompany.* and
zero references from desktop-api types. No backend internal use either —
the database/API-server layers keep their methods, only the IPC exposure
goes.
Removed IPC handlers (26 total):
Thread CRUD surface (12) — writes happen inside the backend
createThread, listThreads, addThreadEvent,
setThreadWaitState, resolveThreadWaitState,
addParticipant, removeParticipant, listParticipants,
subscribeThread, unsubscribeThread,
listSubscriptions, listSubscribedThreads
(kept reads: getThread, listThreadEvents, listThreadWaitStates)
Half-implemented features (13)
saveSecret, listComments, getAgentMetrics, getApiPort,
listWakeupRequests, listAgentMessages, autoAssignTask,
listRunEvents, recoveryDismiss,
getHeartbeatPolicy, setHeartbeatPolicy,
getRuntimeState, setRuntimeState
Whole vertical slice: openConnectorAuthTerminal
IPC handler + channel + preload + DesktopApi type +
connector-auth-service function + useConnectorCallbacks
handleLoginConnector + OnboardingContext.onLoginConnector +
OnboardingWizard wiring. Pi-mono has no CLI auth flow;
the no-op that "returned false for legacy UI compatibility"
had no legacy UI left.
Orphaned modules (zero importers in repo):
src/main/runtime-env.ts
src/main/database-stale-recovery.ts
Dead schema files (only referenced by their own barrel re-export):
schemas-participants, schemas-subscriptions, schemas-heartbeat,
schemas-run-events, schemas-runtime-state, schemas-wakeup
Renderer leftovers:
.activity-row-enter CSS + its two unreferenced keyframes
5 orphan i18n keys (app.console, app.refresh, app.expand,
activity.timeline, auto.trigger) left over from earlier feature
deletions
Also update two unit tests that mocked openConnectorAuthTerminal in
the RegisterCoreHandlersDependencies pick.
|
||
|
|
fd6471988a |
refactor(app): remove Console feature entirely
Per user request: the "显示控制台 / Show console" button in the top-right header and the bottom drawer it opened are no longer needed — the Runs section already renders the same log tail in-page via RunDetail, so the drawer was a duplicate surface. Deleted the entire feature instead of leaving a hidden toggle that attracts bugs. Removed files: - src/renderer/views/ConsoleView.tsx (126 LOC) Removed renderer integration points: - App.tsx: header "Show/Hide console" button, ConsoleView render, setConsoleOpen(true) call after startRun, TerminalWindow icon import - views/sections/ExecutionSections.tsx: onOpenConsole callback, setConsoleOpen subscription - components/runs/RunCenter.tsx: onOpenConsole prop (removed from forwarding chain) - components/runs/RunDetail.tsx: onOpenConsole prop + the Lightning "Open console" ActionButton. The cancel-run button remains as the sole Panel action now that the console entry point is gone. - hooks/useUIController.ts: consoleOpen / setConsoleOpen subscription - hooks/useDesktopEvents.ts: "toggle-console" shortcut dispatcher - state/slices/navigation.ts: consoleOpen field, setConsoleOpen, toggleConsole actions, and the two auto-open-on-navigate triggers that used to flip the drawer when jumping to a run Removed main-process integration: - main/desktop-shell-menu.ts: "Toggle Console" menu item + CmdOrCtrl+` accelerator - main/desktop-shell-window.ts: onShortcut union type narrowed - shared/types/events.ts: ShortcutEvent action union narrowed Removed i18n keys (zh + en): - app.hideConsole / app.showConsole - console.title / console.noRuns / console.close - common.openLog / common.noRunSelected / common.waitingForLog - runs.openConsole Log capture itself (logBufferRef in useCompanyController + run-log events) is unchanged — the Runs section still displays it. Nothing was moved to a "disabled but present" state; the feature is out of the tree entirely. Typecheck and lint pass. |
||
|
|
ab731e64d2 |
fix(onboarding): actually probe shell PATH for node; stop returning Electron's bundled version
The `checkNodeInstalled` IPC handler was hardcoded to
`{ installed: true, version: process.version }`. That meant:
1. The NodeCheckStep in onboarding ALWAYS passed, regardless of what
was actually on the user's machine.
2. The "Node.js vX.Y installed" success banner showed the **Electron
runtime's** bundled Node version, not the one the agent's bash
tool will invoke. A user with no shell Node saw "Node.js v22.x
installed" and moved on, then every agent bash call that executed
`node -e 'fetch(...)'` (the pattern the API prompt-builder strongly
recommends in `api-docs/header.en.md`) crashed at runtime.
Replaced the stub with an `execFile("node", ["--version"])` probe that
runs in the same PATH the bash tool will later inherit. Added a 3s
timeout so a hung PATH entry doesn't stall onboarding. On failure the
handler returns `{ installed: false, version: null }` — the existing
NodeCheckStep already renders the platform-specific install
instructions (brew / winget / nvm) in that case.
No UI change; the fake-pass was the bug. Covers the onboarding audit
item about misleading health checks.
|
||
|
|
36bed1b0f1 |
feat(team): add TeamHeaderSummary strip + delete unreachable orgchart section
Two S-level audit items landed together because they are the same
"first-second orientation" concern on the team page:
S1 — Empty OrgChart was the first thing the board saw; the canvas
carried no summary data. Now a compact summary strip sits above
the chart showing Members / Running / Attention / Pending hires /
This-month budget (spent/cap/%). All five numbers computed from
the existing snapshot — no extra IPC.
S9 — Activity and budget context previously required visiting three
separate pages. The summary strip collapses the minimum-useful
subset into a single glance; the full drill-down paths (Work,
Approvals, Costs) remain one click away.
S10 — The `orgchart` SectionId was a dead rendering branch:
- Not in the sidebar (useUIController sidebarGroups)
- Not in the command palette (paletteOnlyNavDefs)
- Not reachable from any navigateTo() call in renderer code
The only callers were an automation post-hire notification and a
unit test — both pointed at "orgchart" as a navigation target.
Routed both to "agents" instead (which renders the OrgChart at
the top anyway, plus the new summary strip and the roster), so
clicking the "Agent Hired" notification now lands on the team
page with the new hire highlighted.
Removed:
- `orgchart` from SectionId enum (src/shared/types/enums.ts)
- `section === "orgchart"` render branch (OrganizationSections.tsx)
- `nav.orgchart` i18n key (zh + en)
Added:
- src/renderer/components/organization/TeamHeaderSummary.tsx
- team.summary.* i18n keys (zh + en)
Covers S1 / S9 / S10.
|
||
|
|
02ba887610 |
fix(runtime): stop gating pi tool calls; Co-Founder approvals live at API layer
The Co-Founder beforeToolCall hook (introduced in
|
||
|
|
4cfb926971 |
refactor(schema): split 852-LOC migration ledger into era files
schema.ts had grown to 852 LOC — migration history is append-only, so
the file gets bigger every release. Organize by era without fragmenting
the history.
| File | LOC | Contents |
|-----------------------------------|-----|---------------------------------------|
| schema.ts | 195 | helpers, runMigrations, |
| | | ensureOptionalColumns, seed, init |
| migrations/types.ts | 47 | Migration/Deps types, column/table |
| | | helpers |
| migrations/v1-v15.ts | 50 | createMigrationsV1V15 — v1..v15 |
| migrations/v16-v25.ts | 157 | createMigrationsV16V25 — v16..v25 |
| migrations/v26-v34.ts | 158 | createMigrationsV26V34 — v26..v34 |
| migrations/v35-v37.ts | 326 | createMigrationsV35V37 — three heavy |
| | | table-rebuilds (runs/tasks/skills) |
Each era exports a factory `createMigrationsVxxVyy(deps)` that takes
`{ db, readAll }` — migrations' `fn` bodies captured those names via
closure in the original; factory pattern preserves the closure shape
byte-for-byte while making the dependency flow explicit.
Master `schema.ts` imports all four and concatenates them into the
final ascending list. Order preserved including the quirky v31 -> v33
-> v32 -> v34 sequence that predated my changes.
All 37 migrations' `version`, `name`, and `sql`/`fn` bodies are
byte-for-byte identical. No behavior change; 578 tests green.
`database-base.ts` (449 LOC) kept as-is. It's a thin linear pass-
through facade — every method is 1–3 lines. Splitting a facade of
this kind by forcing mixins or abstract classes costs more than it
saves. Not a god component in the Linus sense (one responsibility:
delegate); flagged here so future readers know it's deliberate.
|
||
|
|
00b0e0aaed |
refactor(ui+skills): split import.ts + primitives.tsx god files
Two more > 400 LOC modules split along responsibility seams. Zero
behavior change — public APIs re-export from the original file paths,
so every consumer import stays working.
| Original | Split into | LOC each |
|---------------------------------------|-------------------------------|----------|
| src/main/skills/import.ts (458) | import (orchestrator) / | 132 |
| | discovery (findSkillMdFiles, | 153 |
| | buildLocalInventory) | |
| | materialize | 24 |
| | import-local | 147 |
| | import-url | 85 |
| src/renderer/components/ui/ | primitives (barrel re-export) | 11 |
| primitives.tsx (419) | Card / Eyebrow / Badge / | 9–59 |
| | Spinner / Panel / Field / | each |
| | Input / Select / TextArea / | |
| | ActionButton / SectionButton | |
`createSkillImporter` still returns `{ importFromSource, importFromGitHub,
importFromLocal, importFromUrl, cleanupSkillFiles, parseGitHubUrl,
findSkillMdFiles, buildLocalInventory }` — every existing consumer
resolves unchanged.
`primitives.tsx` is now a pure barrel re-exporting the 11 primitives.
Consumers can migrate to direct imports (`../ui/Card`) opportunistically
but the barrel path keeps working.
|
||
|
|
dbdec86cdd |
refactor(database): split 5 god modules into domain-scoped files
Five files under `src/main/database/` exceeded 400 LOC by combining multiple responsibilities. Split along natural fault lines. Zero behavior change — each original `build*Methods` factory still returns the same method surface, now via spread of smaller builders. | Original | Split into | LOC each | |-----------------------------------|-------------------------------------|----------------| | tasks-crud.ts (640) | tasks-crud / task-sessions-crud / | 250 / 252 / | | | task-queries | 177 | | agents-hierarchy.ts (487) | agents-hierarchy / agent-permissions| 350 / 151 | | company-settings.ts (476) | company-settings / company-metrics | 122 / 370 | | documents.ts (426) | documents / config-revisions | 318 / 126 | | runs.ts (413) | runs / run-logs / run-events / | 245 / 96 / | | | cost-entries | 82 / 52 | AppDatabase call sites unchanged: - db.tasks.saveTask, db.tasks.ensureAgentSession (task-sessions-crud), db.tasks.listAgentTasks (task-queries) - db.agents.getChainOfCommand, db.agents.hasAgentPermission - db.company.getCompanySettings, db.company.getStandupReport (company-metrics) - db.documentsMod.saveDocument, db.documentsMod.recordConfigRevision - db.runs.createRun, db.runs.appendRunLog (run-logs), db.runs.appendRunEvent (run-events), db.runs.recordCostEntry (cost-entries) `database-base.ts` (449) kept as-is — thin facade where every pass-through is one line; splitting adds mechanical files without behavior benefit. Will revisit if it grows further. Aggregator files (`tasks.ts`, `agents.ts`, `company.ts`) each import and spread the new sub-builders. No renamed exports. |
||
|
|
f012f8e880 |
refactor(skills): drop company_skills.markdown column, read from disk via IPC
`company_skills.markdown` stored the full SKILL.md body. The on-disk
file under `profile/skills/{companyId}/{slug}/SKILL.md` is what pi-
coding-agent actually reads via `loadSkills` at runtime. The DB column
was a duplicate — import wrote to both, renderer read from DB. Any
discrepancy between disk and DB was invisible until the agent behaved
differently from what the user saw in SkillDetail.
Disk becomes the single source of truth. DB keeps name / description /
metadata / source info / file inventory — the cheap lookup row. UI reads
SKILL.md content live via a new IPC channel.
## Migration v37 (rebuild company_skills without markdown)
Table-rebuild pattern matching v35/v36: `fn:` form, PRAGMA walk for
drift columns, row-count assertion before DROP, `defer_foreign_keys`
for the `agent_skill_assignments(skill_id)` reference. Indexes
recreated. Idempotent via `schema_migrations` ledger.
Existing DB rows' markdown content is discarded — but the on-disk
SKILL.md files are the authoritative copy, so no user-visible data
loss. Any agent using those skills keeps working; the UI just starts
reading from disk instead of the DB cache.
## Code changes
- `src/main/database/skills.ts`: drop `markdown` from read mapping,
upsert input type, INSERT column/bind list, ON CONFLICT DO UPDATE.
- `src/shared/types/records-core.ts`: `SkillRecord.markdown` removed.
- `src/main/skills/import.ts` + `import-github.ts`: drop `markdown:`
from 4 `saveCompanySkill` call sites. Disk-write via
`materializeSkillFiles` unchanged (it's what the file agent reads).
- `src/main/skills/index.ts`: expose `skillFilesDir` for the new IPC
handler to resolve `${profile}/skills/${companyId}/${slug}/SKILL.md`.
- `src/shared/contracts/channels.ts` + `schemas-execution.ts` +
`desktop-api/skills.ts`: new `getSkillMarkdown` channel
`{ companyId, slug } → DesktopResult<string>`.
- `src/preload/index.ts`: expose `getSkillMarkdown(companyId, slug)`.
- `src/main/ipc-skill-handlers.ts`: handler validates schema, ensures
company, resolves path, `readFile`, returns content. Fails closed
with `SKILL_NOT_FOUND` on ENOENT, `SKILL_READ_FAILED` on other
read errors.
- `src/renderer/components/skills/SkillDetail.tsx`: `useEffect` fetches
markdown via IPC when the skill detail loads; shows "Loading…" until
resolved, surfaces read errors. Preserves `stripFrontmatter`.
- Tests: drop `markdown:` from 2 fixture sites
(`skill-types.test.ts`, `skill-aware-assignment.spec.ts`).
## Verification
- typecheck / lint / 578 tests green
- `grep 'skill\.markdown' src/` → 0 DB-record reads
- `grep '\.markdown\b' src/main/database/skills.ts` → 0
- Remaining `.markdown` references are all `ghSkill.markdown` (GitHub
fetcher's intermediate struct feeding `materializeSkillFiles` to
disk) and parameter names in `stripFrontmatter(markdown: string)` /
fs-writer helpers — none read from a DB row.
|
||
|
|
7e1d4199ee |
refactor(schema): make runs.task_id nullable, delete heartbeat workaround
The NOT NULL constraint forced heartbeat runs to synthesize a fake task row (`[Heartbeat] X — Check for work`) so the FK could resolve. Three layers of code — acquireHeartbeatTask, findReusableHeartbeatTask, cleanupDuplicateHeartbeatTasks, plus the is_heartbeat flag column + UI filter — existed to paper over one bad constraint. Remove the constraint and the whole workaround vanishes. SQLite table rebuild with data preservation. Live production DB, no backup — migration is atomic, idempotent, and verified before DROP. ## Migration v35 (rebuild runs with nullable task_id) Uses `fn:` form so it can: - Snapshot `COUNT(*) FROM runs` before copy - Walk `PRAGMA table_info(runs)` to detect drift columns (v34 declared a column set; any additive ALTER since v14 shows up here) - Assert `COUNT(*) FROM runs_new == beforeCount` before `DROP TABLE runs` - `PRAGMA defer_foreign_keys = ON` so run_events(run_id) FK defers to commit time, by which runs has the same id set under the new name - Recreate all 6 indexes on the new table If the assertion fails, the migration throws inside the existing `transaction()` wrapper. SQLite rolls back the whole migration, old `runs` table is untouched, `schema_migrations` never records v35. Next app start retries cleanly. ## Migration v36 (drop tasks.is_heartbeat + synthetic rows) Runs now carry `task_id = NULL` for heartbeat dispatches. The `is_heartbeat` flag is dead data going forward. 1. `UPDATE runs SET task_id = NULL WHERE task_id IN (SELECT id FROM tasks WHERE is_heartbeat = 1)` 2. `DELETE FROM tasks WHERE is_heartbeat = 1` (synthetic rows, not real work) 3. Rebuild `tasks` without the `is_heartbeat` column (same defer-FK + row-count-assert pattern as v35) ## Code cleanup - `RunRecord.taskId: string | null`; `createRun` / `cancelRunForDeletion` / `finishRun` guard every `UPDATE tasks` path with a null-check. - `TaskRecord.isHeartbeat` removed; every SELECT / filter / UI reference dropped. - Delete `acquireHeartbeatTask` / `findReusableHeartbeatTask` / `cleanupDuplicateHeartbeatTasks` — dead after the constraint lifts. - Delete `src/main/orchestration/heartbeat-task.ts` (pure workaround constant file). - `heartbeat.ts` creates runs with `taskId: null` when the agent has no assigned work. Turn context already supports nullable task (heartbeatFallbackSection); prompt surface unchanged. - `validateTaskCreation` drops the `isHeartbeat` parameter — user- authored + automation-authored tasks are never heartbeat. - Renderer: `run.taskId ? taskMap.get(...) : undefined` in Dashboard / RunCenter / RunDetail / RunQueue. - Delete `tests/unit/heartbeat-coalescing.test.ts` (12 tests that exercised the deleted DB methods). - Drop 1 isHeartbeat-bypass test from `task-quality-gates.test.ts`. ## Grep verification - `isHeartbeatTaskTitle src/` → 0 - `acquireHeartbeatTask src/` → migration comment only - `is_heartbeat src/` → schema.ts migration history only - `isHeartbeat src/` → 0 Tests: 578/578 (was 591 — delta is 13 tests that tested deleted code). |
||
|
|
92795f7bfb |
refactor(session): scope pi sessions to agents instead of tasks
Sessions were keyed by (task, agent). Every task switch started a fresh
transcript — the agent lost everything it had learned on the previous
task. That breaks the product premise: a CEO should remember its past
decisions, an engineer should carry context between bugs.
Move pi's AgentMessage transcript to be agent-scoped. One session per
agent (per company). Tasks enter the ongoing conversation as user turns;
rotation/compaction boundaries follow the agent's long-running session.
Additive schema only — no row rewrites on the live production DB.
- Migration v34: `ALTER TABLE agents ADD COLUMN session_id TEXT` +
`idx_agents_session_id`. `ensureOptionalColumns` mirror for fresh DBs.
- `AgentRecord.sessionId?: string | null` in the read model; included
in every full-agent SELECT (entity-crud, snapshot, agents-hierarchy
chain-of-command / direct-reports / heartbeat-eligible).
- `db.ensureAgentSession({ agentId, companyId, adapterType })` —
idempotent: returns existing `agents.session_id` if set, else inserts
a new `task_sessions` row with an empty `task_id` sentinel (agent-
scoped, task-id is now metadata) and stores the id on the agent.
- `db.setAgentSessionId(agentId, sessionId)` — used by rotation.
- `db.updateTasksActiveSessionForAgent(agentId, sessionId)` — mirrors
the agent's current session onto every task the agent owns, so
existing per-task readers (`tasks.active_session_id`) still work.
- `turn-context.resolveSession`: pulls the agent's session via
`ensureAgentSession` instead of `getActiveTaskSession(task.id)`,
then points the current task at it so UI continues to resolve.
- `session-management-service.rotateSession`: new session carries
`taskId:""`, then `setAgentSessionId` + `updateTasksActiveSessionForAgent`
so every task the agent owns follows the rotation boundary.
- `session-entries.listActive`: `ORDER BY created_at, sequence` — the
created_at timestamp is authoritative; sequence stays as same-ms
tiebreaker. Forward-safety for future merge scenarios.
Existing agents' first post-upgrade run creates a fresh agent-scoped
session. Their pre-migration per-task transcripts remain in the DB as
historical records but are not loaded for new runs. This is a one-time
transcript reset, accepted over a risky lossy merge on the live DB.
Documented in a code comment on `ensureAgentSession`.
|
||
|
|
5a3647e98e |
refactor(prompt): collapse en/zh mirror to English + runtime locale directive
Prompt builder kept two full translations side by side — system-en.ts +
system-zh.ts, operating-en.ts + operating-zh.ts, and 38 *.zh.md files
across api-docs, sops, ceo-modes, department-guidelines, and
wake-reasons. Any change required hand-syncing across two locales; drift
was guaranteed. The Chinese user wasn't getting Chinese thinking — the
model reads whatever prompt we give it and responds in whatever language
we tell it to.
English is now the single source of truth. At the tail of the assembled
system prompt, when `ctx.locale === "zh"`, append a directive:
Respond to the user in Chinese.
Claude, GPT, and Gemini all comply with the directive reliably. English
users (the default) get no footer.
- Deleted 38 `*.zh.md` files (api-docs 11, sops 15, ceo-modes 5,
department-guidelines 7, wake-reasons 1 via zh.ts).
- Deleted `messages/system-zh.ts`, `messages/operating-zh.ts`,
`messages/locale.ts`.
- Simplified `messages/index.ts` to a direct re-export of the English
bundle; getters no longer take a locale argument.
- Stripped zh imports + locale branching from `api-section.ts`,
`sop-section.ts`, `operating-instructions.ts`,
`operating-instructions-ceo.ts`, `api-endpoints.ts`, `wake-reasons/`.
- `buildSOPSection()`, `getSOP()`, `renderWakeReason()` drop the locale
arg; callers updated.
- Footer injection in `system-prompt.ts` (lines 246-249), placed after
the importantRulesSection so the model reads it last (highest
instruction salience).
`ctx.locale` stays on `PromptContext` — load-bearing for the footer.
Anthropic prompt cache hits improve (one cache key instead of two for
the same agent across locales).
|
||
|
|
40040b3dd2 |
refactor(errors): surface 57 deferred catch-and-swallow sites via logger
The earlier sweep cleaned the hot path (worker/runtime/automation/api-
server/ipc core — 59 sites). This pass finishes the deferred long tail:
main/* services, database/* CRUD modules, api-server/* routes, ipc-*
handlers, thread-integration, automation helpers, browser, skills.
Default strategy: catch (error) { logger.warn("[module] context:", ...) }
— preserve current behavior, make the failure visible instead of
silently hidden. Widened 3 logger interface signatures to include warn
where callers only passed info/error previously (thread-helpers,
host-environment-service, ipc-domain-common).
Legitimate boundaries left intact: JSON.parse on untrusted input, DNS
resolver no-records paths, explicit safe* helpers (safeJson, parseJson,
parseTriggerDetail), URL-parse predicates, and `<unserialisable>`
fallbacks inside the prompt-builder summarisers. Each kept catch now
either has a reason in context or is a documented safe* helper.
Tests adjusted only where test-constructed no-op loggers needed a warn
stub (message-dispatch, company-mode-switch, ipc-agent-graph-handlers).
With the earlier pass, AgentCompany no longer has silently-swallowing
catch blocks in main-process or renderer business code.
|