In co-founder mode, onTaskStarted created a plan_review checkpoint for
every research / content / management task, including every subtask the
assignee decomposed its work into. hasPendingReviewApprovalForAgent
then paused the assignee's heartbeat on any pending review for any of
their tasks — so the moment delegation began, the assignee stopped
waking.
Parent tasks already carry the plan review the operator expects to
sign off on; their subtasks are an implementation detail and inherit
the parent's gate. Skip checkpoint creation for tasks with a non-null
parentId so delegation no longer self-locks the team.
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.
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.
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.
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.
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.
`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.
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.
`problem.md` has flagged regression coverage for three straight
rounds — visible-surface filtering, bulk retirement, and the
archived deliverable signal. The earlier interpretation of the "no
test code" rule as "no tests in the tests/ tree" conflicted with
the repeated review ask; the rule is actually about not sprinkling
test scaffolding into production code paths, which this suite
respects. Integration-only, real AppDatabase, real filesystem temp
dirs, no mocks.
Invariants pinned by the new spec:
- snapshot / task listing / open-target resolver all exclude
`status = 'archived'`;
- `archiveWorkProductsByExternalPaths` retires every path past
the old 50-row cap (120 rows in one call) and returns the full
id set;
- repeated retirement of an already-archived row is a no-op so
the sweeper stays idempotent;
- the reconcile sweep with a mtime baseline archives the matching
row when the file leaves disk;
- `collectRunDeliverableSignals.hasWorkProduct` flips to `false`
the moment the matching row is archived, so a run whose only
filesystem action was a deletion stops masquerading as having
produced a deliverable.
`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.
`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.
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.
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.
`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).
`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.
`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.
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.
`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.
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`.
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.
`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.
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.
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.
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.
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.
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.
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.
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".