Commit Graph
159 Commits
Author SHA1 Message Date
ZacharyZhang-NY d13c9d83e8 Resolve filesystem work product targets by existing file 2026-04-22 20:06:01 -04:00
ZacharyZhang-NY fa006524c2 refactor(runtime): centralize lifecycle constants 2026-04-22 16:58:36 -04:00
ZacharyZhang-NY 775598ef4c fix(db): make task row state authoritative 2026-04-22 16:24:57 -04:00
ZacharyZhang-NY 3aaa64ddfd refactor(runtime): enforce source file size boundaries 2026-04-22 15:56:09 -04:00
ZacharyZhang-NY eefb515aa2 fix(runtime): classify run usage status 2026-04-22 15:41:19 -04:00
ZacharyZhang-NY 43583ef30b fix(runtime): gate cofounder checkpoints 2026-04-22 14:05:26 -04:00
ZacharyZhang-NY 7a1dd647aa fix(api): make task updates transactional 2026-04-22 14:00:24 -04:00
ZacharyZhang-NY 19f8494d09 fix(db): make filesystem lock intent explicit 2026-04-22 13:51:41 -04:00
ZacharyZhang-NY 0785c9ba47 fix(db): make task checkout atomic 2026-04-22 13:38:54 -04:00
ZacharyZhang-NY 9fba8dd6c3 fix(projector): stop writing null workspace_id on work_products
Two sites in upsertFilesystemArtifact leaked null workspace_ids
into the work_products table and produced the orphan rows the read
path now has to heal:

1. Create path unconditionally forwarded ctx.workspaceId, which was
   null whenever a run was created without a workspace binding
   (historically cognitive runs, or runs that lost their link through
   the observationPath regression fixed in 1f2f977).

2. Update path passed {workspaceId: ctx.workspaceId} as part of the
   patch. updateWorkProduct treats any key present in patch as "write
   this value", so a run touching a previously-well-linked row with
   ctx.workspaceId=null would clobber the existing workspace_id back
   to null — actively destroying data.

Resolve both at the projector layer:

  - Add workspacesMod.findWorkspaceIdByLocalPath(companyId, localPath)
    as a reverse-lookup. workspacePath is the argument the projector
    is already walking; it came from some workspace row's local_path,
    so finding that row by path is deterministic.
  - Compute resolvedWorkspaceId = ctx.workspaceId ?? reverseLookup(...)
    once, and use it for both the identity lookup
    (findWorkProductByExternalId) and the subsequent write.
  - On the update path, only include workspaceId in the patch when
    resolvedWorkspaceId is non-null. Null now means "no info — leave
    the existing column alone" instead of "clobber to null".

Together with the read-path heal in cfb8ba5 this closes the loop:
new files never enter the table with a null workspace_id if we can
possibly resolve it, old files keep their links, and truly unlinkable
rows still fall back to the observation workspace on open.

Typecheck and build:verify clean.
2026-04-21 14:52:38 -04:00
ZacharyZhang-NY cfb8ba5dc1 fix(docs): heal orphan work_products by falling back to observation workspace
Work-product rows written before the artifact projector tracked
workspace_id end-to-end can lose the link through all three joins:
workspace_id is NULL, task_id is NULL, and the row's run exists but
its runs.workspace_id is also NULL. Those orphans were readable in
the DocumentCenter (the listing doesn't need a workspace root) but
could not be opened — resolveWorkProductTarget returned null and the
toast now reports workspace_unresolved.

Add a fourth resolution tier in resolveWorkProductTarget: if the
three-step chain yields nothing, look up the company's observation
workspace (primary first, then oldest) and check whether the
external_id resolves to a real file inside that root. If it does,
return that absolute path and the row opens. If the file isn't
there, the error falls through with an extended detail line naming
the observation root that was tried, so the operator sees exactly
why the heal also failed.

This is deterministic — exactly one fallback workspace — and
side-effect-free on the read path: the row itself is left alone,
no stealth write-back. A follow-up commit will stop the projector
from writing NULL workspace_id in the first place.

Typecheck and build:verify clean.
2026-04-21 14:51:06 -04:00
ZacharyZhang-NY 390f76fa76 fix(docs): split work-product open errors into actionable reasons
The previous handler folded every null target into one error code
("WORK_PRODUCT_NOT_FOUND: Work product not found or its workspace
is unresolved"). When a user couldn't open a file we had no way to
tell whether the row was archived, the provider/external_id was
empty, the workspace had no local_path, or the external_id escaped
the root.

Widen resolveWorkProductTarget's return from (target | null) to
(target | error) where error carries one of five reasons:

  not_found             row missing for id+company
  archived              status == "archived" tombstone
  no_target             provider != filesystem and no url
  workspace_unresolved  all three workspace_id / task_id / run_id
                         lookups returned no local_path
  path_escape           external_id is absolute or steps outside root

Each reason includes the concrete detail (ids checked, paths tried)
so the toast on the renderer side tells the user exactly which slot
is null without having to open the DB.

The IPC handler maps each reason to its own failure code and passes
the detail through, so WorkProductRow's toast now reads e.g.
"workspace_id=null task_id=null run_id=null — none led to a
workspace with a local_path" instead of a generic unresolved.

Typecheck and build:verify clean.
2026-04-21 14:45:59 -04:00
ZacharyZhang-NY 1f2f9770a6 Hotfix: separate workspace observation from lock claim — restore artifact projector for cognitive runs
Production regression of T1 (7829acc..15e3863). User reports "明明生成
了很多文件,但只有一个" — the filesystem shows 19 markdown deliverables
in ~/Documents/Github/test/{business-plan,operations,ceo-coordination}/
but the DocumentCenter surfaces only one row.

Root cause is structural, predicted in spirit by Karpathy's Round 1
take ("把'认知'和'执行'两件事一起锁是数据结构错"). The T1 rewrite
collapsed two concerns into one nullable field on the run context:

  workspacePath = workspace?.localPath ?? null
                  ^^^^^^^^^                ^^^^
                  lock target              observation root

For lockMode='none' (every cognitive C-suite agent post-T1) the
resolver returns workspace=null, so workspacePath was null, so
artifact-projector.ts:242 short-circuited every projection with
`{ kind: "skipped", reason: "no_workspace" }`. The agents kept
writing files via bash (cwd falls back to the company default
workspace at worker/runtime/index.ts:142), but nothing observed
them — the DocumentCenter only saw the one file that was projected
back when the agent still held an exclusive workspace.

Fix: split the two concerns. WorkspaceResolution gains a non-lock
`observationPath` field; the resolver populates it from the company
primary workspace (new `getCompanyObservationWorkspace` query —
primary first, oldest second, null only when company has zero
workspace rows) for lockMode='none' runs, and from `workspace.localPath`
itself for lockMode='exclusive' runs. RunDispatchParams threads the
field through; turn-context's `workspacePath` reads it first.
artifact-projector.ts is unchanged — it just sees a non-null path
again and resumes baselining, diffing, and upserting work_products
rows the way it did pre-T1.

Three new unit tests pin the regression: cognitive role with
workspace ⇒ observationPath set; cognitive role with no workspace ⇒
observationPath null; exclusive ⇒ observationPath equals
workspace.localPath. typecheck green, 543/543 unit tests, 10/10
eval. No fallback, no mock, no silent degradation.
2026-04-20 16:22:40 -04:00
ZacharyZhang-NY 15e3863cba saveTask: accept requires_filesystem; opt in for the onboarding kickoff task
- saveTask now takes an optional requiresFilesystem boolean. Omitted
  → column keeps its current value on updates, defaults to 0 on
  inserts. Explicit true/false → written through.
- Onboarding kickoff (the CEO's very first task) sets
  requiresFilesystem=true. The task description already tells the
  agent to list directories and read README/package.json — that is
  filesystem work, so the task must hold the workspace exclusive.
- Other saveTask call sites (automation reassign, routes-tasks patch,
  create_task, meetings) stay implicit — update paths preserve the
  existing column value, new user-created tasks default to 0 (safe
  cognitive default) and can be opted in later by the creator.
2026-04-20 15:36:08 -04:00
ZacharyZhang-NY 28cdf7c56f Rewrite resolveWorkspace to per-task lockMode inference; drop company_fallback silent share (B4-adjacent)
Old resolver: task → agent → project → any company workspace, all
marked exclusive. That fourth step folded every runnable agent in a
multi-agent company onto a single workspace row and serialised the
C-suite (four agents, one exclusive lock, three starved).

New resolver: lockMode='exclusive' is now an opt-in decision inferred
per task. Three rules:

  1. task.workspaceId set → exclusive on that workspace (explicit).
  2. filesystem signal (task.requiresFilesystem OR agent in a
     coding role/engineering department) → exclusive on
     agent.workspaceId / project primary; throws if neither exists.
  3. otherwise → none / null. Pure cognitive work runs with no
     workspace lock and fully in parallel.

Removed:
  - company_fallback silent-share path. Filesystem-touching runs
    with no legitimate binding now throw WorkspaceConfigurationError
    at resolve time so the caller decides recovery rather than the
    resolver picking a random shared workspace.
  - workspacesMod.getAnyCompanyWorkspace (now dead code).

Invariants now enforced by types:
  - lockMode='exclusive' ⇒ workspace is a live WorkspaceRecord.
  - lockMode='none'      ⇒ workspace is null.

Tests updated to cover the new decision table and the four-non-
coding-agents parallel case that was the original bug.
2026-04-20 15:32:01 -04:00
ZacharyZhang-NY 7829acc7f0 Add tasks.requires_filesystem column (v52) — per-task filesystem lock policy
Before v52, resolveWorkspaceWithLockMode walked a four-step fallback
chain (task → agent → project → any company workspace) and marked
every resolved row exclusive. In a multi-agent company every heartbeat
collapsed onto the same company workspace row, every run claimed the
same exclusive lock, and only one agent ever executed at a time.

Root cause is a missing column: per-task 'does this task touch the
filesystem?' had no place to live, so the orchestrator defaulted to
yes on everything. v52 gives that question its own column.

Backfill is conservative:
  - in-flight tasks (in_progress + active_run_id) → 1 (preserve
    the lease the dispatcher already holds)
  - todo/backlog tasks whose assignee is an engineering-flavoured
    role (CTO/engineer/developer/devops/sre or department =
    engineering) → 1
  - everything else → 0

TaskRecord now exposes requiresFilesystem on both entityCrud.getTask
and the snapshot read path.
2026-04-20 15:25:36 -04:00
ZacharyZhang-NY b628b49016 Hotfix B2 Phase 1: drop task_leases.run_id FK (v51 migration)
Production bug on launch — user reports UI 'FOREIGN KEY constraint
failed' on 启动公司 and the main-process log loops forever with
'[heartbeat] CEO not ready: checkout_race_lost'. Both trace to the
same root cause introduced by v50 (e2c839b..2656684 B2 Phase 1):

  task_leases.run_id TEXT NOT NULL REFERENCES runs(id)

The orchestration sequence contradicts this FK. In both heartbeat.ts
(line 165) and task-runner.ts (line 91), the caller generates a fresh
runId = randomUUID() and immediately calls db.tasks.checkoutTask
BEFORE the corresponding row is inserted into the runs table. The
legacy tasks.checkout_run_id column has always been 'dangling
allowed' — the run row saves later when task-runner.ts opens the
run record. v50's FK tightened the shadow lease contract without
coordinating with that insertion sequence, so every checkoutTask
attempt now rolls back with a FK violation, takes the whole CAS
transaction with it, returns changes=0, and the heartbeat reports
checkout_race_lost. The task never advances — the exact orphan
symptom B2 was supposed to eliminate.

Fix: migration v51 recreates task_leases WITHOUT the run_id FK.

- task_id FK + ON DELETE CASCADE stays (a lease for a non-existent
  task is still meaningless)
- UNIQUE(task_id, run_id) stays (still a correctness constraint)
- run_id loses the FK — it mirrors the legacy checkout_run_id's
  loose semantics, which is exactly what Phase 1 dual-write is
  supposed to mirror
- Existing data preserved verbatim via INSERT SELECT
- Uses PRAGMA defer_foreign_keys=ON inside the transaction (the
  pragma is valid inside a txn; regular foreign_keys=OFF would not
  be since runMigrations already opened BEGIN IMMEDIATE)

Post-v51 the run_id column is still NOT NULL and the shadow probe
(ab4afba) will still surface missing runs as divergence — the FK
tightening is a Phase 2 concern coordinated with the saveRun
insertion order, not a Phase 1 shadow-write concern.

typecheck green, 529 unit tests green, 8/8 eval green.
2026-04-20 14:44:59 -04:00
ZacharyZhang-NY 54a97f67ac B2 Phase 1 follow-up: plug lease orphan gap in advanceTaskStatus + scope interruptStaleRuns cleanup
Linus review T1 surfaced three real bugs in the B2 Phase 1 shadow
dual-write surface. Fix them in one pass.

1. advanceTaskStatus left task_leases behind on terminal (done /
   cancelled / blocked) and "return to queue" (todo / backlog)
   transitions. The status->phase trigger advanced tasks.phase but the
   lease row lingered as an orphan. dev-mode verifyPhaseConsistency
   could see the divergence; production silently drifted. Wrap the
   status UPDATE and an explicit DELETE FROM task_leases in a single
   transaction so no caller observes the intermediate state.

2. The no-restricted-syntax guard in eslint.config.mjs only matched
   literals shaped "UPDATE tasks SET status" -- advanceTaskStatus's
   dynamic `UPDATE tasks SET ${timestampSets.join(", ")}` slipped past,
   and so did saveTask / recoverStaleLocks (status not first column).
   Tighten the selectors to (a) match any status-write within UPDATE
   tasks regardless of column order, and (b) flag template literals
   whose head ends with `SET ` so dynamic helpers must carry an
   explicit eslint-disable with rationale. Five grandfathered sites
   now carry matching disables.

3. interruptStaleRuns scoped its task+lease cleanup with
   `WHERE finished_at = ?` against the freshly stamped timestamp --
   safe in isolation, vulnerable to ISO-millisecond ties with other
   already-interrupted runs. Capture the stale run ids before
   mutating anything and drive every downstream statement from that
   explicit id list. Original semantics preserved; scoping is exact.

Unit tests added in tests/unit/tasks-phase.test.ts cover the
advanceTaskStatus lease cleanup across done / cancelled / blocked /
todo, and pin the in_review case as "lease stays".
2026-04-20 13:51:53 -04:00
ZacharyZhang-NY 118e3d8795 Remove final AppDatabase pass-through methods with ESLint guard (C1 part 3/3)
Migrate all remaining pass-through call sites (social, skills, budget,
entityCrud, documents, knowledge, automation, wakeup, backup, threads,
settings, snapshot, connectors, workspaces, activity) to their owning
domain modules. `AppDatabase` now carries only three methods:

  - createHireRequest (multi-module composite: agents + approvalsMod)
  - rollbackConfigRevision (multi-module composite: documents + entityCrud)
  - queryAll (raw-SQL escape hatch, @internal)

The vault/env-binding infrastructure previously passed argument-by-
argument into `entityCrud.saveSecret`/`resolveSecretEnv` now binds at
module construction, letting callers invoke `db.entityCrud.saveSecret({...})`
without carrying vault plumbing.

Structural types that narrowed AppDatabase to a bag of flat methods
(`ConnectorModelPolicyDeps` callers, `CapabilityMatchDb`, test mocks in
budget-enforcement / budget-incidents / task-quality-gates /
failure-escalation / task-quality-gates-sweep) are reshaped to the new
namespace. `connectorPolicyDepsFromDb` replaces the old pattern of
handing the whole AppDatabase to `validateConnectorModelSelection`.

A new ESLint rule in eslint.config.mjs blocks new top-level methods on
AppDatabase so the pass-through layer cannot silently grow back.
`AppDatabase.ts` dropped from 193 lines to 58; `AppDatabaseBase` lost
the 270-line trailing pass-through block.
2026-04-20 13:31:39 -04:00
ZacharyZhang-NY 411294afa6 Lint rule: block new UPDATE tasks SET status outside advanceTaskPhase (B2 Phase 1 step 5/5) 2026-04-20 13:00:53 -04:00
ZacharyZhang-NY ab4afba9c7 Add phase/status consistency check observability (B2 Phase 1 step 4/5) 2026-04-20 12:57:24 -04:00
ZacharyZhang-NY 2656684392 Dual-write phase and lease in task status transitions (B2 Phase 1 step 3/5) 2026-04-20 12:55:53 -04:00
ZacharyZhang-NY 8cb227f455 Add advanceTaskPhase and task lease helpers with tests (B2 Phase 1 step 2/5) 2026-04-20 12:53:28 -04:00
ZacharyZhang-NY b9040adad5 Add phase column and task_leases table with backfill (B2 Phase 1 step 1/5) 2026-04-20 12:53:20 -04:00
ZacharyZhang-NY e530849b0f Review iteration: warn on gitSha read failure, WAL invariant comment, C6.2 wave-3 coordination gate
- backup.ts: log.warn when `git rev-parse HEAD` fails so sandbox/CI runs
  with no git show diagnosably instead of silently emitting gitSha:null
  (Karpathy review ask).
- backup.ts: expand comment on VACUUM INTO to pin the WAL-mode invariant
  — future readers must not "helpfully" add a wal_checkpoint call
  (Karpathy review ask).
- C6.2 proposal: add hard coordination gate before wave 3 because
  commit 0db6e42 already demonstrated the race hazard when two agents
  touch scripts/ / connector-definitions/ in the same window
  (Linus review ask).
2026-04-20 12:33:45 -04:00
ZacharyZhang-NY d7e267031f Drop unused agent_messages.read_at column via migration v49 (M1)
The `agent_messages.read_at` column has had no production writer since
v12 moved read tracking into the per-viewer `agent_message_reads`
receipts table. It survived anyway, carrying a NULL through every SELECT
list and begging the next maintainer to accidentally read stale data
from it.

- v49 migration: `ALTER TABLE agent_messages DROP COLUMN read_at`
  (idempotent: skips if absent).
- messages.ts: SELECTs no longer fetch the column; per-viewer `readAt`
  is computed from `agent_message_reads` as before, with `null` for
  call-sites that have no viewer context (getAgentMessage /
  searchMessages / listAgentInboxUnread).
- runtime-bridge.ts / runtime-message-injector.ts: comments referencing
  the dropped column updated to name the read-receipts table instead.
2026-04-20 12:20:32 -04:00
ZacharyZhang-NY dade5eb333 Wire pre-migration auto-backup with named API and manifest (B2/backup precursor)
Before running any pending migration, take a synchronous snapshot of the
profile to backups/pre-migration-v<target>-<ts>/ and write a
backup-manifest.json recording reason, schema version, app version, and
git SHA. A failed migration now has a well-labelled recovery point
instead of a half-upgraded profile.

- createAutomaticBackup(reason): synchronous snapshot via VACUUM INTO +
  recursive cpSync of logs/artifacts, plus JSON manifest. No fallback —
  failure throws so the caller refuses to touch the schema.
- runMigrations accepts createAutomaticBackup and invokes it when any
  migration is pending. No optional chaining; callers must supply it.
- AppDatabase constructor takes appVersion so main-runtime stamps the
  manifest with app.getVersion().
2026-04-20 12:16:39 -04:00
ZacharyZhang-NY 7833ded4a1 Make @mentions visible to the agents they name
A DM addressed to one agent that @-mentions another was silently
dropping the second agent out of the conversation:

* `dispatchAgentMessage` added the mentioned agent to the wake set,
  so CEO's heartbeat fired — but
* `listAgentInboxUnread` only surfaced messages where the current
  agent was `to_agent_id` or sat in a matching channel/project/
  department. Mentions were invisible, so CEO's next turn saw nothing
  new and returned silently. From the operator's chair this looked
  exactly like "CEO ignored me; 张工程师 answered instead".
* `findMentionedAgentIds` had a latent bug on top: its unique-alias
  filter used `ids.length === 1`, but a single agent whose name
  matched its role registered itself twice, tripped the filter, and
  every `@CEO` in a scaffold with role === name was a no-op. Once
  inbox visibility was fixed the bug became load-bearing.

Root-cause fixes:

* New migration v48 adds `agent_message_mentions(message_id, agent_id,
  company_id, created_at)` so mention fan-out is a first-class join.
* `sendAgentMessage` now takes the resolved mention set and writes
  the row + mentions in a single transaction — a crash between the
  two writes can no longer leave a message visible to the recipient
  but invisible to the mentioned agent.
* `dispatchAgentMessage` computes the mention set exactly once via
  `resolveMentionedAgentIds` and hands the same list to both the DB
  write and `resolveMessageWakeTargets`, so persistence and wake
  stay in lockstep.
* `listAgentInboxUnread` and `listAgentMessages` gain an `id IN
  (select message_id from agent_message_mentions …)` clause so the
  mentioned agent's inbox actually contains the message that woke it.
* `findMentionedAgentIds` now stores per-alias agent IDs in a `Set`
  and filters on `ids.size === 1`, dropping the self-duplication
  bug while preserving the "refuse ambiguous aliases" semantics.
* Company teardown drops `agent_message_mentions` alongside the
  existing `agent_message_reads` cleanup so deletes stay clean.

Tests pin the new behaviour: a DM with a `@CEO` mention reaches
CEO's inbox and both agents get woken, while a DM whose recipient
is `__board__` never attempts to wake the human sentinel.
2026-04-20 08:21:43 -04:00
ZacharyZhang-NY 6b22fa9f0e Free tasks from the checkout_race_lost deadlock
Two bugs combined to park successful-but-deliverable-less tasks in an
unrecoverable state. Operator saw Alex Chen loop on
`checkout_race_lost` every two minutes for 16+ minutes in the field
log after completing its first task.

1. `processRunCompletion` in automation-run-completion.ts fired a no-op
   status transition (`in_progress` → `in_progress`) when a run
   succeeded without producing the expected deliverable. advanceTaskStatus
   no-ops on identical prev/next, and handleTaskStatusChange logged its
   `caller should gate this` warning and returned. finishRun had already
   released active_run_id and checkout_run_id, so the task sat at
   `in_progress` with no lock, no run — the exact orphan pattern that
   checkoutTask's `status IN ('todo','backlog')` clause refuses.
   Transition the row to `blocked` with the diagnostic comment attached
   so the manager's blocker queue surfaces it, the assignee stops
   spinning, and effectiveStatus carries the real state downstream.

2. `checkoutTask` itself refused to reclaim any orphan `in_progress`
   row, even when active_run_id was already null. That refusal was the
   right invariant against concurrent re-entry but wrong for the orphan
   case a crashed or status-unchanged run leaves behind. Extend the CAS
   predicate to accept `status = 'in_progress' AND active_run_id IS NULL`
   — heartbeat candidate selection already filters out tasks whose
   active_run_id points at a live run, so the guard preserves the
   original mutual-exclusion property for the busy case while allowing
   the orphan reclaim the scheduler actually needs.
2026-04-19 21:09:16 -04:00
ZacharyZhang-NY 0c67779012 Drop stale active_run_id when a task returns to todo/backlog
advanceTaskStatus cleared checkout_run_id when transitioning out of
in_progress but left active_run_id alone. Heartbeat selects candidate
tasks by !activeRunId, so a task recovered from a crash (moved
blocked → todo by the bootstrap recovery) could carry a dead
active_run_id pointer and remain silently unpickable by every future
wake, even though the run it points to is long since interrupted.

interruptStaleRuns normally clears active_run_id for tasks whose
owning run is now interrupted, but the guarantee relied on a single
startup pass. Make advanceTaskStatus defensive: when the terminal
state is todo or backlog, clear the pointer too. The invariant holds
end-to-end instead of relying on interruptStaleRuns having seen every
edge case.

Also harden the new-CEO team-building cue: make it an explicit
imperative ("call the bash tool to run the fetch POSTs now") and
route app_restart_resume through the same branch so a startup-crash
recovery wake lands on the same focused action script.
2026-04-19 20:09:33 -04:00
ZacharyZhang-NY f59acd2778 Garbage-collect orphan inbox projection ledger rows
agent_message_projections accumulates one row per
(message_id, session_id) pair and previously had no purge path — the
table grew monotonically for the life of the process. Once a session
is rotated away no agent references its id as the active one, and its
ledger rows exist only to prevent replay against a shadow that no
future run will touch.

Add purgeOrphanProjections (delete rows whose session_id no longer
appears in agents.session_id) and wire it into the housekeeping
scheduler with a 24h safety margin so rotations in flight during the
sweep cannot strip the ledger under an active run.
2026-04-19 19:32:31 -04:00
ZacharyZhang-NY 2b2a6446cb Wake the CEO when the team stalls, not only when tasks are zero
The bootstrap gate fired on goalCount > 0 && taskCount === 0, which
missed the common failure mode: every task stuck in_review or blocked
so the counter stays positive while no progress happens. The CEO
then waited for the normal timer interval to spot the stall.

Add countAdvancingTasksForCompany (todo + in_progress only) and gate
bootstrap on that. Also pull messages to the top of the timer wake
step list so an agent waking on the normal cadence doesn't skip over
unanswered peer DMs in favour of its own task work.
2026-04-19 19:27:26 -04:00
ZacharyZhang-NY 0443b4a197 Block approve_ceo_strategy until hires exist
The CEO prompt carried a plea — "do not submit strategy before hires,
we have observed a loop" — in place of an actual gate. The model
could (and did) ignore it, land back in team_building mode, and
resubmit. Move the invariant into the /approvals POST handler:
reject approve_ceo_strategy with 409 STRATEGY_BEFORE_HIRES when no
hire_agent approval exists for the company, and 409 STRATEGY_ALREADY_FILED
when one is already pending or approved.

Introduce listCompanyApprovalsByType on the database facade to keep
the route stateless. Trim the prompt to describe the gate instead of
begging the model to obey.
2026-04-19 19:22:34 -04:00
ZacharyZhang-NY b6184b5bf3 Document readAt viewer semantics and drop dead column from SELECTs
AgentMessageRecord.readAt has always been a per-viewer field — what
the snapshot calls "readAt" means "the Board operator has
acknowledged", what listAgentMessages calls "readAt" means "this
agent has acknowledged". The hidden contract was not documented and
the SELECT list kept pulling agent_messages.read_at even though no
code writes it, inviting the same bug §1.1 just fixed.

Spell out the viewer-scoped contract in snapshot.ts and drop the
dead column from the agent_messages SELECT list; readAt is resolved
exclusively from agent_message_reads.
2026-04-19 19:20:21 -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 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 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 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 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 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 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 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 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 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