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.
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.
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.
Purge remaining hardcoded color values from the component layer and main
process so every surface flows through the kami token system.
src/main/desktop-shell-window.ts
- BrowserWindow.backgroundColor no longer paints pure white before the
renderer mounts. It now picks kami parchment (#F5F4ED) or deep-dark
(#141413) based on nativeTheme.shouldUseDarkColors so the startup
flash respects the user's OS theme preference.
src/shared/constants.ts
- DEPARTMENT_COLORS collapses 12 unrelated hex brand colors (violet,
red, green, cyan, pink, ...) into six kami tokens: var(--primary),
var(--kami-ink), var(--kami-dark-warm), var(--kami-charcoal),
var(--kami-olive), var(--kami-stone). Departments are distinguished
by warm-neutral ramp + ink-blue, never by second chromatic hue.
src/renderer/components/communication/{MessageItem,SearchResults}.tsx
- Replace the string-concat tint pattern `${color}18` with
color-mix(in oklab, ${color}, transparent 91%). The old pattern
produced invalid CSS (e.g. `var(--primary)18`) whenever `color`
was a CSS custom property, silently falling back to no background.
color-mix works for both hex and var() inputs, eliminating the
latent bug at the source.
src/renderer/components/ui/ActionButton.tsx
- Replace hand-rolled rgba(74,93,122,0.35) and rgba(220,53,69,0.3)
drop shadows with kami ring shadows routed through color-mix over
var(--primary) and var(--destructive).
src/renderer/views/CommandPaletteView.tsx
- Replace rgba(15,23,42,0.08) cold-slate shadow with a kami whisper
shadow driven by color-mix over var(--foreground).
src/renderer/components/skills/SkillAgentToggles.tsx
src/renderer/components/execution/ModelManagementShared.tsx
- Toggle thumbs switch from bg-white to bg-background so they
inherit parchment in light mode and deep-dark in dark mode,
removing the last pure-white surfaces from the renderer.
Third-party brand hex values in src/renderer/components/social/
social-config.ts are retained: they represent external brand identity
(Twitter blue, YouTube red, ...), not kami chrome, and collapsing them
to one hue would destroy recognition.
Typecheck clean. No business-logic signatures changed.
The 1f2f977 hotfix repairs the projector for new runs, but files that
already exist on disk before the fix lands stay invisible to the
DocumentCenter — a baseline+diff projector reports "changed" only
when a file's mtime advances past the run-start baseline, and a
quiescent file written before the fix never advances.
This commit adds a bootstrap-time one-shot rescan:
- New module `bootstrap/backfill-workspace-artifacts.ts` walks the
primary/oldest workspace of every company exactly once (gated by
`artifact_rescan_v1_done` in the settings table), applies the same
registrability filter as `artifact-projector.ts` (skip .git/,
node_modules/, .DS_Store, .lock/.log/.map; ignore package-lock.json
/ pnpm-lock.yaml / yarn.lock; cap at 5 000 files per workspace),
and upserts a `work_products` row via `findWorkProductByExternalId`
→ `createWorkProduct` for any file the projector never registered.
- bootstrap-application.ts wires the call after recoverScheduledPosts
(DB ready, services running, before the first heartbeat tick), so
the rescan completes in the same boot tick that the user opens
the DocumentCenter.
Idempotency: subsequent boots short-circuit on the settings flag.
The post-fix projector handles all future file activity inside
each run, so the backfill never needs to run twice. Per-workspace
file cap prevents a pathological tree from locking the bootstrap
path; overflow files are still picked up by the next run that
touches them. No fallback, no mock — files that fail stat are
silently skipped (same policy as the projector).
typecheck green, 543/543 unit tests, 10/10 eval. The user's 19
existing markdown deliverables under ~/Documents/Github/test/
should appear in the DocumentCenter on the next `pnpm dev`.
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.
Closes the three specific_asks from Jobs's T1 review of the workspace
architecture fix (7829acc..15e3863):
1. docs/architecture/workspace-concurrency.md: decision table
upgraded from a code block to a real Markdown table so it reads
consistently with the failure-modes table lower in the file.
2. docs/architecture/workspace-concurrency.md + resolve-workspace.ts:
added "Sunset coding-role inference" section and a TODO marker on
CODING_ROLE_PATTERN tying the regex to the v52 transition window
(target 2026-05-20). Both documents now name the end-state
explicitly so a future engineer knows why the regex exists and
when it dies.
3. resolve-workspace.ts: enriched the WorkspaceConfigurationError
message to include taskId / agentId / projectId alongside the
existing companyId, so a misconfigured setup points operators at
the offending row instead of just the company.
No behaviour change, no fallback, no mock. typecheck green, 540 unit
tests green, 10 eval tests green.
- 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.
- heartbeat.ts and task-runner.ts now share a single ResolveWorkspaceFn
signature exported from run-dispatch.ts. Callers handle
WorkspaceConfigurationError explicitly:
* heartbeat: release the task lease and skip with new
workspace_unresolved reason (task re-enters pool next heartbeat)
* task-runner: release the lease and return WORKSPACE_UNRESOLVED
to the caller
No silent fallback to a random shared workspace.
- crash-recovery.ts imports WorkspaceConfigurationError for clean
catch-branch attribution; the outer try already rolls back the
queued run on any resolve failure.
- run-dispatch.ts exposes ResolveWorkspaceFn so downstream modules
type their injected resolver against the same shape (single source
of truth).
- HeartbeatSkipReason gains workspace_unresolved to keep the closed
enum honest about all heartbeat exit paths.
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.
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.
Old internal-tool-selector attached `file_approval` to any agent with
`reportsTo === null`, and attached both tools through the same
management-keyword predicate chain. That let a VP or director with
org-management authority walk away with `file_approval`, contradicting
the C4 commit narrative that scoped `approve_ceo_strategy` as CEO-only.
Introduce `canFileCeoStrategy(db, companyId, agentId)` as a dedicated
predicate requiring both a CEO-equivalent role/title AND
`reportsTo === null`. Using either half alone is too loose: role alone
lets a demoted CEO with a virtual superior still file strategy,
`reportsTo` alone lets a VP with no manager slip through. The two
conditions together pin the predicate to a genuine top-level CEO.
- `hire_agent` continues to gate on `canRequestHire` (managers hiring
ICs is a legitimate flow and the original "CEO-only" framing in the
C4 commit message was narrower than the production reality).
- `file_approval` now gates on `canFileCeoStrategy`, orthogonal to hire
authority.
- New unit test covers 4 edges: genuine CEO, CEO title with virtual
superior, VP without manager, and plain IC.
The C4→C5 drift bug (hotfix 12556de) was caused by the same RPC
method list being declared twice — once as a zod enum in
worker/entry/types.ts for runtime validation, and once as a TS
union type in runtime-bridge.ts for compile-time handler wiring.
The two drifted when C4 added `invokeInternalTool`: the TS union
grew, the handler switch added a case, but the zod enum was
forgotten, so the runtime gate rejected envelopes the handler had
already registered.
This commit collapses the two declarations into one:
- Extract `workerRpcMethodSchema = z.enum([...])` in
worker/entry/types.ts as the canonical list.
- Export `WorkerRpcMethod = z.infer<typeof workerRpcMethodSchema>`.
- Replace the hand-written `RpcMethod` union in runtime-bridge.ts
with `type RpcMethod = WorkerRpcMethod` plus an explicit import.
- Reuse the schema inside the `rpc-request` variant of
`workerMessageSchema` so adding a method requires exactly one
edit instead of three.
- Add an exhaustiveness `default: { const _exhaustive: never = ... }`
guard to `handleRpc`'s switch. If the zod enum widens without a
matching case, TypeScript fails the build at the `never`
assignment — drift becomes a compile-time error, not a runtime
ZodError fired at the user.
typecheck clean, 529 unit tests green, 8/8 eval green.
Production bug — as soon as the CEO agent tries to call the hire_agent
or file_approval internal tool introduced by C4 (e2c839b..03864f3),
the main process spams:
[error] [worker] invalid message payload ZodError: ...
with all seven discriminant branches failing. Every branch reports
'invalid_value' on 'type' because zod's discriminated-union validator
walks every variant when the discriminant does not match any
literal — which happens because the worker sent
`{ type: "rpc-request", method: "invokeInternalTool", ... }` and the
'rpc-request' branch's `method: z.enum([...])` only listed five
methods: persistMessage / prepareRun / beforeToolCall / afterToolCall
/ transformContext. 'invokeInternalTool' was missing.
Root cause: C4 added the RPC method on both sides of the fence —
worker-side caller (hire-agent-tool.ts:133, file-approval-tool.ts:115)
and main-side handler (runtime-bridge.ts:565, RpcMethod TS union at
line 77) — but forgot to register it in the zod validation schema
at worker/entry/types.ts that sits between them. The TS union type
would have caught it at compile time if the enum were the single
source of truth; right now the enum is duplicated as a zod literal
list + a TS union, and C5's compile-time hook contract only covers
pi's own hooks, not the RPC message envelope.
Fix: add "invokeInternalTool" to the zod enum. One line.
Follow-up proposal C5-followup: unify the two duplicated enum
definitions by either (a) deriving the TS union from the zod
schema via z.infer, or (b) making runtime-bridge.ts import the
enum values from worker/entry/types.ts so adding an RPC method
requires touching exactly one place. This will be documented in
docs/proposals/ once the hotfix lands and the app is known to
work end-to-end again. Scope for that cleanup is small (one file
edit + one type move) but out of scope for this hotfix because the
user is currently unable to run the CEO bootstrap.
typecheck green, 529 unit tests green, 8/8 eval green.
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.
Closes review asks from cross-review of C4:
- Jobs ask #2: team-building.en.md still referenced the bash+fetch
pattern for goal creation. Rewritten as a deferred-tool note pointing
to C5 follow-up, without re-teaching the HTTP shape C4 just removed.
- Karpathy ask #1 (partial): re-recorded manager-task-handoff against
main to produce post-C4 positive evidence. VP Engineering has
hire_agent available via canRequestHire, but when directed NOT to
hire, the recorded trace shows task decomposition only (no
hire_agent invocation). 455 in / 769 out tokens; assertions updated
to pin /api/task/create + agent_ic_ slugs + p99 substring + max 3
turns. The `non-ceo-dm-reply` re-record is deferred to the follow-up
ticket.
- Karpathy asks #2, #3, #4 + Jobs asks #1, #3, #4: documented in
docs/proposals/C4-followup.md with concrete completion criteria.
Scope covers canFileCeoStrategy predicate, non-ceo-dm-reply
re-record, RFC 8594 Deprecation/Sunset headers + counter, and a
negative substring assertion kind for the eval harness.
Eval still green at 8/8. C4 main batch (e2c839b..8e99e01) stands
unchanged; this commit is pure follow-up groundwork.
Rewrite `ceo-modes/team-building.en.md` to describe the `hire_agent`
and `file_approval` pi tools introduced in C4 parts 1-3, replacing the
pre-C4 Node.js `fetch` snippets that showed the CEO how to POST to
`/api/companies/:id/agents` and `/api/companies/:id/approvals`.
The SOP now:
- Lists `hire_agent` args as a table (name / role / connectorId /
model + optionals) instead of a JSON body with hard-coded headers.
- Lists `file_approval` args the same way, pointing at
`approve_ceo_strategy` as the canonical use case.
- Preserves the `STRATEGY_BEFORE_HIRES` / `STRATEGY_ALREADY_FILED`
ordering gate, surfaced through the tool-layer error codes
(`file_approval.strategy.before_hires` / `.already_filed`).
- Keeps the goal-creation fetch snippet because goal creation is not
yet exposed as an internal tool; flagged with a "use `bash` for now"
hint so the CEO does not conflate the two patterns.
HTTP deprecation:
- `POST /api/companies/:id/agents` carries `@deprecated since C4`
JSDoc and emits `log.warn('[deprecated][C4] ...')` on every
successful call so residual legacy callers (UI forms, automation
tests, unmigrated scripts) are auditable in logs.
- `POST /api/companies/:id/approvals` with `type=approve_ceo_strategy`
emits the same warn; other approval types still use the HTTP route
and are not flagged (they have no tool equivalent yet).
Neither route is removed — existing clients keep working. Removal is
gated on tool-path eval coverage landing in a follow-up.
tests: pnpm typecheck + pnpm test:unit (529) + pnpm test:eval (8/8) green