Commit Graph
649 Commits
Author SHA1 Message Date
ZacharyZhang-NY 2222a67428 Stop rendering the DocumentCenter as a blank page
Both the prose-documents and work-products sections rendered as
`{x.length > 0 ? ... : null}` with no else branch, and the whole
DocumentCenter was only mounted when a company was selected. The
combined result: a company with no files gave the operator a page
containing only a title and a "New" button, and an operator who had
not yet picked a company saw nothing at all — the exact "page is blank
even though docs were generated" complaint.

Add an explicit empty state inside DocumentCenter (surfaced only when
both collections are empty) and a separate "select a company" empty
state at the section router level. Strings live in the i18n bundle so
the Chinese locale is not left behind.
2026-04-19 19:10:57 -04:00
ZacharyZhang-NY 905473d985 Unify message read state on agent_message_reads
listAgentInboxUnread's direct-channel branch filtered on
agent_messages.read_at, a column no code path in the repo ever writes.
Every DM was therefore reported unread on every wake — the standup kept
re-surfacing the same message, agents either looped on duplicate replies
or devolved into posting status comments instead of real replies.

Route every channel through agent_message_reads. The legacy read_at
column stays in the schema as a wire-shape placeholder but is explicitly
documented as unused; every surface that returns readAt already derives
it from the receipts table.

Also delete the wake-reason instruction that taught agents to post a
status comment when nothing needs attention — that line was the direct
source of heartbeat-noise comments cluttering the activity feed.
2026-04-19 19:08:41 -04:00
ZacharyZhang-NY d51682b06d Surface the agent's own outputs in standup
The standup briefing knew about tasks, approvals, comments, and
report statuses but had no view of what the agent itself recently
produced. Without that view the model could narrate "I wrote the
PRD" in its assistant text without ever calling POST /documents,
and we'd have no automated way to catch the drift — the user just
saw an empty document page and a confused agent.

Standup now returns:

  - `recentDocuments` — documents the agent authored (capped at 20),
    with id / type / title / status / version / updatedAt;
  - `recentWorkProducts` — filesystem artifacts produced on tasks
    the agent owns (capped at 20), with title / externalId / url /
    status / task title / updatedAt.

The CEO operating prompt was already updated in the previous
commit to reference these fields and re-do a missing POST when a
task claims completion but the persistence is absent. The trust-
but-verify loop is now closed: the agent can compare its own
narration against its own actual outputs every wake.
2026-04-19 18:38:19 -04:00
ZacharyZhang-NY 4eaad1f91a Surface unread inbox in the standup briefing
Pre-fix the agent had to make a second `?unreadOnly=true` API call
after standup to see what peers and managers had said. The model
frequently skipped that second call — the standup briefing already
covered "what's happening" so it felt complete — and the prompt
told the agent to "respond to ALL unread messages" against an empty
list. Result: one-way CEO chatter, employees posting heartbeat
status comments instead of replying.

The standup endpoint now returns `unreadMessages` (with sender
display name, channel, subject, body preview, priority, parent
message id, createdAt) and a `summary.unreadMessages` count. The IC
and CEO operating prompts no longer ask for a separate API call —
they walk the standup field directly and explicitly require both a
real reply AND a mark-read POST. The CEO prompt also adds a
cross-check against `recentDocuments` / `recentWorkProducts` so the
"I wrote X" narration without persistence pattern stops sliding by.
2026-04-19 18:36:50 -04:00
ZacharyZhang-NY f30ec740d8 Stop welding "delivered" onto "read"
The inbox projection used to call `markAgentInboxDelivered` after
appending each message to the agent's session shadow. That helper
sets `agent_messages.read_at = now()` for direct messages and
inserts into `agent_message_reads` for channel messages — the SAME
flags `listAgentInboxUnread` and the public `?unreadOnly=true` API
use to define "unread". So the moment we put a message into the
recipient's transcript, the recipient's own API call returned
nothing pending. The IC prompt told the agent to "respond to ALL
unread messages"; the API said zero unread; the agent posted a
status comment instead of replying. Every silent-employee report
traces back here.

Replay safety lives entirely on `agent_message_projections`
(installed in v46) — that ledger is keyed by `(message_id,
session_id)` and `claimProjection` already short-circuits a re-run
without touching `read_at` at all. The two concepts are now
properly separated:

  - "delivered" (we put it in front of the agent) — tracked by
    the projection ledger, owned by the runtime bridge;
  - "read" (the agent acknowledged it) — tracked by `read_at` and
    `agent_message_reads`, only flipped by the explicit POST
    /messages/{id}/read endpoint the agent calls itself.

`markAgentInboxDelivered` is removed because there is no remaining
caller and its semantics were the bug — keeping it around would
just invite the same mistake again.
2026-04-19 18:34:49 -04:00
ZacharyZhang-NY 3a72c8aa39 Skip cleanly when channel-target resolution fails
`dispatchAgentMessage` requires a `channelTargetId` for `department`
and `project` channels, but the default "Notify department on task
completion" rule is agent-agnostic — it has to derive the target at
fire time. Single-agent companies and any sender without a
`department` produced a WARN every run completion that read like a
real failure and made the log tail look like the system was broken.

The action handler now treats "channel needs a target and we
couldn't resolve one" as "no recipient available" and skips with an
INFO line. The dispatcher is no longer asked to validate an input we
already know is incomplete, and the operator stops seeing
`CHANNEL_TARGET_REQUIRED` rejections during routine completions.
2026-04-19 18:21:24 -04:00
ZacharyZhang-NY ba66a6bdc3 Skip in_review for autonomous top-level completions
In `ai_ceo` mode the operator opted out of human review, but
`processRunCompletion` still advanced every successful run through
`in_review`. The top-level CEO has no manager to walk up to and no
human gate, so the task became a dead letter: the heartbeat candidate
filter only picks `todo / in_progress / blocked`, so the CEO never
saw its own stuck rows and looped on taskless heartbeats every two
minutes producing nothing.

The previous workaround was even uglier: `advanceToInReview`
unconditionally force-flagged `requires_user_review = true` on every
top-level completion just to bypass `guardInReviewHasReviewer`. The
guard's own comment said "the right root fix is to reject at source";
the workaround did the opposite.

`decideTerminalStatus` is the new single source of truth: it returns
`done` only when the assignee is top-level AND the company is in
`ai_ceo` mode AND `reviewDeliverables` is off. Every other shape —
non-top-level, ai_cofounder, or operator-opted-in deliverable review
— still routes to `in_review` so existing manager-review and
human-review flows are unchanged. `advanceSuccessfulRun` is the
single entry point both successful branches call so the deliverable
and non-deliverable paths cannot diverge again.
2026-04-19 18:19:59 -04:00
ZacharyZhang-NY 8be61f141e Exclude archived work_products from the deliverable signal
`collectRunDeliverableSignals` queried the full `work_products`
table by run provenance. When a run's only filesystem action was a
deletion, the retirement stamped `last_touched_by_run_id` onto the
archived row — and the deliverable gate read that stamp as
"produced an artifact", letting `determinePostRunStatus` advance
the task to `in_review` for content the agent had just removed.

Pin `status <> 'archived'` onto the query so retirement tombstones
never count as deliverable evidence. A run that actually edits a
file still flips the row back to `active` via `upsertFilesystemArtifact`
before the signal is read, so real edits remain visible; only
deletions — which leave the row archived — stop masquerading as
produced output.
2026-04-19 17:30:50 -04:00
ZacharyZhang-NY fe808d13a2 Archive every deletion via a bulk SQL retirement
`retireDeletedArtifacts` used to iterate `deleted.slice(0,
MAX_BASH_PROJECTED_FILES)` and emit no signal for paths past the cap
— a large `rm -r` would leave the 51st row onward visible in the
DocumentCenter with no indication anything was missed. The cap was
inherited from the registration path, where it bounds file-stat
round-trips; retirement is a pure metadata update with no filesystem
cost, so the cap was value-free safety theatre.

New DB surface `archiveWorkProductsByExternalPaths` runs a single
`UPDATE … SET status = 'archived' … WHERE external_id IN (…)
AND status <> 'archived' RETURNING id, external_id` per 500-path
chunk — one round-trip per chunk instead of one per path. The
projector feeds the full `deleted` list in, maps the returned ids
back to `retired` outcomes, and no deletion is left behind regardless
of the run's cleanup fan-out size.
2026-04-19 17:23:07 -04:00
ZacharyZhang-NY f0c4a66cc1 Hide archived work_products from the visible surface
Retirement set `status = "archived"` but none of the downstream
readers honoured it. The snapshot pulled every row, the task-scoped
listing returned every row, the DocumentCenter rendered every row,
and `resolveWorkProductTarget` would happily hand a deleted file's
path to `shell.openPath`. The promise that archived rows "stop
surfacing" was only true inside the projector's own doc comment.

Pin the visibility gate at the three DB read paths:

  - `listSnapshot().workProducts` — the canonical renderer source —
    excludes archived;
  - `listWorkProductsForTask` (task API + thread view) excludes
    archived;
  - `listWorkProductsForCompany` excludes archived.

`resolveWorkProductTarget` now refuses to resolve an archived row, so
`openWorkProduct` fails with `WORK_PRODUCT_NOT_FOUND` instead of
routing the OS shell at a dead path. Archived rows stay in the table
as retirement tombstones — operators still have the audit trail;
they just no longer leak into the operator UI.
2026-04-19 17:21:31 -04:00
ZacharyZhang-NY 0813e691cd Retire work_products whose files the run deleted
Fix #16 kept deletions from registering, but rows created by earlier
runs stayed `status = "active"` forever even after the agent `rm`'d
the file. The DocumentCenter kept showing zombies and
`openWorkProduct` resolved to an empty path.

`ChangeSet` now carries both `changed` and `deleted` paths, populated
from the two detectors:

  - git porcelain: the `D` status entries that were previously
    dropped are now captured into `deleted` instead;
  - baseline diff: paths present in the baseline but no longer on
    disk at diff time are `deleted`.

A new `retireDeletedArtifacts` pass runs alongside
`projectChangedPaths` and flips the matching
`work_products.status` to `archived`, bumping `last_touched_by_run_id`
to the retiring run so the provenance timeline records the deletion.
`upsertFilesystemArtifact` sets `status = "active"` on update so a
subsequent run that recreates the path brings the row back into the
visible set. A new `ProjectionOutcome` variant — `retired` — lets the
hook and reconciliation logs report both sides of the fan-out.
2026-04-19 16:56:22 -04:00
ZacharyZhang-NY 090a656f91 Constrain the diff when the baseline was truncated
`captureBaselineIfRequired` stops walking at `MAX_BASELINE_ENTRIES`
and flags `truncated = true`, but nothing downstream honoured the
flag. `diffAgainstBaseline` then treated every file past the sample
cap as "new" because `baseline.mtimeByRelPath.get(rel)` was
`undefined`, so a non-git workspace with more than 20k files could
report thousands of untouched files as deliverables on every bash
call.

When the baseline is truncated, the diff now reports only the
sampled paths whose mtime advanced. Unsampled paths stay outside the
diff — we cannot distinguish "always there" from "just written"
without the full snapshot, and silently mis-classifying is worse
than missing the tail. `collectChangedPaths` propagates the flag as
`coverageTruncated` and both the bash and the reconciliation entry
points append a `baseline_truncated` skip outcome so operators see
the degraded signal (typical remediation: `git init` the workspace
so `git status` becomes the authoritative diff source).
2026-04-19 16:53:07 -04:00
ZacharyZhang-NY f9349f374c Detect git workspaces with rev-parse instead of a .git stat
`isGitRepo` only accepted a `.git` directory, so linked worktrees and
submodules — both of which store `.git` as a file containing
`gitdir: …` — silently fell into the non-git branch. The baseline
diff then replaced `git status` as the source of truth for workspaces
that git itself recognises, and edge cases where the baseline is
missing (a freshly-created worktree mid-run, a submodule without a
captured snapshot) bled into `no_baseline` skips instead of running
the authoritative diff.

Call `git rev-parse --is-inside-work-tree` and take git's own yes/no
answer. Exit 0 means git would happily run `status` there; any other
outcome (no git binary, corrupted ref, genuinely non-git) lands us on
the baseline path exactly as before.
2026-04-19 16:51:44 -04:00
ZacharyZhang-NY aaa31ea5ae Skip baseline walk on git workspaces
`prepareRun` used to call `captureWorkspaceBaseline` on every
workspace with a path, even when `.git` was present. The baseline was
silently discarded by `collectChangedPaths` (the git branch wins) but
the walk still paid for itself — an O(tree) scan on every run start,
capped at 20k files, with the only effect being that the first 20k
entries got captured before the capper truncated the tail.

Collapse the decision into `captureBaselineIfRequired`: the function
short-circuits to null for git workspaces so `git status` stays the
sole source of truth, and only scans the tree when the baseline is
the only source available. The call site in `runtime-bridge` now
passes through without the per-call conditional.
2026-04-19 16:34:38 -04:00
ZacharyZhang-NY 925b0fae77 Exclude deleted paths from the artifact projector
Two bleed-throughs let a removed file land in `work_products` and
plant a ghost row:

  - `listChangedRelPaths` pushed every non-`!!` git porcelain entry,
    including worktree-deleted (`Y = D`) and staged-deleted-with-
    synced-worktree (`XD` + ` `) rows, so the projector tried to
    upsert a path that was no longer on disk.
  - `upsertFilesystemArtifact` never stat'd the absolute path; a
    deleted file (from either source detector) still reached
    `createWorkProduct` and left the DocumentCenter "open" link
    pointing to nothing.

Filter the two deletion forms at the git list step and add an
`existsSync + isFile` gate in `upsertFilesystemArtifact`. The check
also catches edge cases the baseline diff surfaces — a file touched
and then `rm`'d, a regular file replaced by a symlink, a path that
raced with a `cleanup` shell script — so neither change detector can
register a row that would not open.
2026-04-19 16:33:46 -04:00
ZacharyZhang-NY d4c6056f5e Diff non-git workspaces via an mtime baseline
`projectBashArtifacts` and `reconcileWorkspaceArtifacts` used to bail
with `not_a_git_repo` whenever the workspace had no `.git`, so bash
output in such workspaces never reached `work_products` and the
DocumentCenter looked empty while files piled up on disk.

`captureWorkspaceBaseline` now walks the workspace at `prepareRun`
time, skipping the same categories the registrability filter rejects
(`.git`, `node_modules`, `.next`, caches) and capping at
`MAX_BASELINE_ENTRIES` so large trees stay bounded. The baseline rides
on `ActiveRunContext.workspaceBaseline` and is passed into every
artifact projection.

`collectChangedPaths` is the single change-detector for both the
per-bash projection and the end-of-run reconciliation. It picks `git
status` when a `.git` is present and falls back to a
`diffAgainstBaseline` walk otherwise. Paths already on the ignore list
still never land in `work_products`; the identity stays
`(company, workspace, provider, externalId)`; the upsert still refreshes
`last_touched_by_run_id` so the deliverable signal follows the latest
touching run.
2026-04-19 16:18:25 -04:00
ZacharyZhang-NY b1d1fbc54f Drain document lifecycle events through a durable processor
Before this commit `createDocumentWithLifecycle` enqueued ledger rows
inside the save transaction but consumed them from an in-memory
`pendingSideEffects` array straight after commit. A crash between the
commit and the drain stranded rows as `pending` forever; a crash mid
handler left them as `running`; and there was no retry budget.

The new `DocumentLifecycleProcessor` is the single drainer. It:

  - claims work via `claimPendingBatch`, an atomic `UPDATE … RETURNING`
    that flips rows from `pending` to `running` while incrementing
    `attempts`, so two drainers cannot race onto the same row;
  - dispatches each row through a handler registered by `hookType`,
    parsing the frozen `payload_json` back into the call arguments;
  - records `succeeded` / `failed` / `retryable` outcomes through the
    ledger, so a transient failure stays on the queue until the attempt
    cap expires, after which it is marked `failed` for operators;
  - reclaims rows stuck in `running` past `staleRunningMs` back to
    `pending` on every sweep tick plus at startup, so a crashed process
    never leaves a row orphaned;
  - runs a periodic sweep so every document — including those whose
    immediate post-commit drain failed — is eventually advanced.

`createDocumentWithLifecycle` now only enqueues and then calls
`lifecycleProcessor.drainForDocument` for the immediate happy-path pass.
Handlers live in `document-lifecycle-handlers.ts` so the processor stays
free of service-graph knowledge. The processor is constructed in
`bootstrap-application`, started there, and stopped on `before-quit`.
2026-04-19 16:14:51 -04:00
ZacharyZhang-NY 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.
2026-04-19 15:53:55 -04:00
ZacharyZhang-NY 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.
2026-04-19 15:50:39 -04:00
ZacharyZhang-NY 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.
2026-04-19 15:46:12 -04:00
ZacharyZhang-NY 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.
2026-04-19 15:43:48 -04:00
ZacharyZhang-NY 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.
2026-04-19 15:13:53 -04:00
ZacharyZhang-NY 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.
2026-04-19 15:05:21 -04:00
ZacharyZhang-NY 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.
2026-04-19 15:01:41 -04:00
ZacharyZhang-NY 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.
2026-04-19 14:59:05 -04:00
ZacharyZhang-NY 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".
2026-04-19 14:55:23 -04:00
ZacharyZhang-NY 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.
2026-04-19 14:51:26 -04:00
ZacharyZhang-NY 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.
2026-04-19 14:43:57 -04:00
ZacharyZhang-NY 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.
2026-04-19 14:38:04 -04:00
ZacharyZhang-NY 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.
2026-04-19 13:24:50 -04:00
ZacharyZhang-NY 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.
2026-04-19 13:20:14 -04:00
ZacharyZhang-NY 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.
2026-04-19 01:34:14 -04:00
ZacharyZhang-NY 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.
2026-04-19 01:25:19 -04:00
ZacharyZhang-NY 0746664f72 feat(review-panel): split reviews by author — board / human / manager
One flat list of `status = in_review` tasks couldn't tell the
operator whether the next decision belonged to the board (approval
pending), to the human user (top-level assignee or explicit
`requires_user_review`), or to a middle manager walking up the
`reports_to` chain. Three very different escalation paths rendered
identically, so "reviewed by X" was the only signal and operators
missed the ones actually waiting on them.

`PendingReviewList` now classifies each in-review task into one of
three kinds (`board` / `user` / `manager`) using the pending
approval set plus the existing assignee/manager lookup. Rendering
groups them under labelled sub-headings in priority order (board
first — the operator is the bottleneck there), each with its own
awaiting-text so the row's status is unambiguous at a glance.

The component accepts approvals via prop; the caller passes the
existing `pendingApprovals` it already holds. Translations for both
locales carry the new group labels.
2026-04-19 01:24:01 -04:00
ZacharyZhang-NY 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.
2026-04-19 01:21:26 -04:00
ZacharyZhang-NY 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.
2026-04-19 01:19:23 -04:00
ZacharyZhang-NY 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.
2026-04-19 01:14:48 -04:00
ZacharyZhang-NY 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.
2026-04-19 01:12:38 -04:00
ZacharyZhang-NY 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.
2026-04-19 01:06:50 -04:00
ZacharyZhang-NY 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.
2026-04-19 01:03:44 -04:00
ZacharyZhang-NY 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.
2026-04-19 01:03:13 -04:00
ZacharyZhang-NY 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.
2026-04-19 01:01:29 -04:00
ZacharyZhang-NY 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.
2026-04-19 00:58:39 -04:00
ZacharyZhang-NY 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.
2026-04-19 00:51:54 -04:00
ZacharyZhang-NY 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.
2026-04-19 00:51:40 -04:00
ZacharyZhang-NY 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.
2026-04-19 00:48:13 -04:00
ZacharyZhang-NY 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.
2026-04-18 23:37:37 -04:00
ZacharyZhang-NY 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.
2026-04-18 23:22:17 -04:00
ZacharyZhang-NY 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.
2026-04-18 23:09:59 -04:00
ZacharyZhang-NY 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.
2026-04-18 22:39:03 -04:00