Commit Graph
43 Commits
Author SHA1 Message Date
ZacharyZhang-NY b839ce4f23 Inject hire_agent and file_approval into CEO agent (C4 part 3/5)
Wire the two internal tools from parts 1-2 end-to-end:

- `invokeInternalTool` RPC method added to RuntimeBridge; worker tool
  `execute` calls route here, handler dispatches by tool name via the
  new `agent-runtime/internal-tools/router.ts`.
- `InternalToolCallbacks` (`onApprovalCreated` / `onApprovalResolved`
  / `onDomainChanged`) are late-bound on the bridge via
  `setInternalToolCallbacks`; `wire-api-server` now cross-wires the
  same callbacks used by the HTTP approval route so thread integration
  / automation fire identically across both entry points.
- `RunJob.internalTools` (new field) carries the per-run tool list
  main decides at dispatch time via `orchestration/internal-tool-
  selector.ts`. Selection rule: `hire_agent` → any agent with
  `canRequestHire` authority; `file_approval` → CEOs only (scoped to
  the `approve_ceo_strategy` use case).
- Worker `createPiAgent` call site now assembles `codingTools +
  internalTools` before handing to pi. Exhaustiveness guard on the
  switch catches new tool names at compile time.

No prompt-level wiring yet — the `initial-user-turn` and
`team-building.en.md` updates ship in parts 4 and 5 so this commit
only adds the tool surface without changing the CEO's observable
behaviour (the existing HTTP path is still the one the prompt tells
the CEO to call).

tests: pnpm typecheck + pnpm test:unit (529) + pnpm test:eval (8/8) green
2026-04-20 14:25:44 -04:00
ZacharyZhang-NY d65f156732 Add internal file_approval tool (C4 part 2/5)
Introduce a first-class `file_approval` pi tool definition + main-side
handler, paired to `hire_agent` from part 1/5. Replaces the
`bash + node -e + fetch POST /api/companies/:id/approvals` chain with
a single pi tool-call the CEO uses to file `approve_ceo_strategy`
after the initial hires. Handler reuses `db.approvalsMod.requestApproval`,
the `approve_ceo_strategy` ordering gate (STRATEGY_BEFORE_HIRES /
STRATEGY_ALREADY_FILED), and the auto-approval policy so the tool path
produces identical approval rows to the HTTP route.

No runtime wiring yet — the injection + RPC router land in part 3/5.

Error taxonomy:
- file_approval.input.invalid / strategy.before_hires /
  strategy.already_filed → UserInputError
- file_approval.create.failed / activity.failed → UpstreamError

tests: pnpm typecheck + pnpm test:unit (529) + pnpm test:eval (8/8) green
2026-04-20 14:18:37 -04:00
ZacharyZhang-NY e2c839be49 Add internal hire_agent tool (C4 part 1/5)
Introduce a first-class `hire_agent` pi tool definition + main-side
handler so the CEO no longer has to route hires through `bash + node -e
+ fetch` against the internal HTTP API. The tool's `execute` method
proxies to main via a new `invokeInternalTool` RPC method (wiring lands
in part 3/5); the handler reuses the existing `createHireRequest` DB
primitive and auto-approval policy so the tool path and the legacy
HTTP path produce identical approval rows.

No runtime wiring yet — the tool is not injected into any agent's tool
set in this commit. That keeps the behavioural surface unchanged for
this step while giving part 3/5 a concrete handler to bind.

Error taxonomy:
- hire_agent.permission.denied / input.invalid / name.invalid /
  routing.* / connector.* / duplicate.* → UserInputError
- hire_agent.create.failed / activity.failed → UpstreamError

tests: pnpm typecheck + pnpm test:unit (529) + pnpm test:eval (8/8) green
2026-04-20 14:17:03 -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 a69fbb053b Codemod db pass-through calls: messages + runs + approvals + company (C1 part 2/3)
Rewrite ~376 call sites for messages, runs, approvalsMod, and company
domains and delete the corresponding pass-through methods on
AppDatabase / AppDatabaseBase. Structural type references
(ReturnType<HandlerDeps["db"]["getRun"]>) and SweepStaleReviewsDeps-style
mocks are updated to route through the owning sub-module.
2026-04-20 13:20:15 -04:00
ZacharyZhang-NY 5e9b7acbc6 Codemod db pass-through calls to domain modules: tasks + agents (C1 part 1/3)
Rewrite ~525 call sites from `db.<method>()` to `db.<module>.<method>()`
for tasks and agents domains (including `getAgent`/`findAgent`/`getTask`
which live on the `entityCrud` field). Delete the corresponding
pass-through methods on AppDatabaseBase. Structural types that narrowed
`db` to a bag of flat methods (CapabilityMatchDb, EscalationDeps,
SweepStaleReviewsDeps) are reshaped to match the new namespace. Internal
callback references inside database.ts/database-base.ts now invoke the
owning module directly (e.g. `this.agents.saveAgent`) rather than the
deleted pass-through.

The codemod lives at scripts/codemod/remove-appdatabase-passthrough.mjs
and filters by owning module + optional explicit method list. It is
idempotent and does not re-rewrite already-namespaced calls.
2026-04-20 13:17:01 -04:00
ZacharyZhang-NY da517b6e12 Aggregate agent-runtime hooks into AgentHooks factory (M10) 2026-04-20 12:46:20 -04:00
ZacharyZhang-NY 0db6e4261f Migrate top-20 high-frequency catch sites to AppError taxonomy (C6.1 part 2/2)
Each of these catches already logged via an accessible `logger` or `log`.
The migration wraps the thrown value through `classifyUnknownError(raw,
fallback)` and emits the log line with the structured `[code]` prefix.
Observable behaviour — return values, fail strings, DB writes — is
unchanged; the only addition is a machine-readable `code` on every log
line so future telemetry can bucket by category.

Sites migrated (11 files, 20 catch sites):

  src/main/browser-action-service.ts             (2) approval lookup, payload JSON
  src/main/ipc-credentials-handlers.ts           (4) save, remove, test lookup, test resolve
  src/main/hire-hook.ts                          (3) getAgent, connector def, outer exception
  src/main/checkpoint-engine.ts                  (2) onTaskStarted, onDocumentDrafted
  src/main/document-lifecycle.ts                 (1) getRun while deriving taskId
  src/main/skills/runtime.ts                     (2) builtin statSync, builtin readdir
  src/main/worker/handlers/run-status-handler.ts (1) run not found during status
  src/main/worker/handlers/run-log-handler.ts    (1) lease renewal lookup
  src/main/notification-service.ts               (1) notification.close during destroy
  src/main/updater-service.ts                    (1) autoUpdater.checkForUpdates
  src/main/ipc-support.ts                        (2) ipc handler throw, emitEvent send

Why only 20 tonight: 248 catches exist in src/main/. Rewriting them all
in one commit is high-diff-risk (collides with Linus's in-flight
database/backup/migration work on database/*, connector-definitions-*,
message-* and scripts/*) and low-value (pure-library catches with no
accessible logger produce dead `void classified` constructs). The
taxonomy module is committed separately so the remaining 228 sites can
land incrementally via the C6.2 proposal.

All 11 files selected sit outside Linus's working set. Verified:
  - pnpm typecheck   clean
  - pnpm test:unit   498 passed
2026-04-20 12:22:52 -04:00
ZacharyZhang-NY 2c6dd8ed08 Reorganize worker observability layer into worker/observability/ (M8 part 3/3)
Collects the five post-run diagnostic + escalation modules into src/main/worker/observability/: run_events persistence, stdout/stderr noise filter, credential-shape log redactor, self-wake circuit breaker, and retry-aware failure escalation pipeline. circuit-breaker.ts and failure-escalation.ts remain as standalone files inside the directory — each owns a distinct state machine that deserves its own boundary — rather than collapsing into a single index.ts. handlers/*.ts and tests/unit/*.test.ts updated to point at the new paths.
2026-04-20 11:41:07 -04:00
ZacharyZhang-NY da3543fcd9 Reorganize worker runtime layer into worker/runtime/ (M8 part 2/3)
Groups the pi Agent lifecycle, factory, signature contract, and env/redaction helpers into src/main/worker/runtime/. WorkerRunner is the module's public entry and lives in runtime/index.ts; pi-agent-factory and pi-hook-contract stay named for their role as visible runtime boundaries; helpers.ts carries ParentMessage/ChildMessage/RunJob types plus redactor + env override utilities. entry/index.ts and agent-runtime/runtime-message-injector.ts updated to import from the new paths.
2026-04-20 11:40:01 -04:00
ZacharyZhang-NY 76d9031a41 Reorganize worker entry layer into worker/entry/ (M8 part 1/3)
Pulls the worker process entry point, parent<->worker IPC plumbing, message validation schema, and crash-recovery handler into a single src/main/worker/entry/ module. No runtime behavior changes — only directory + file-name renames plus import-path updates: worker-entry.ts -> entry/index.ts, worker-message-handler.ts -> entry/message-handler.ts, worker-rpc.ts -> entry/rpc.ts, worker-crash-recovery.ts -> entry/crash-recovery.ts, worker-types.ts -> entry/types.ts. electron.vite.config.ts, worker-service.ts, handlers/*.ts, pi-agent-factory.ts, and worker-runner.ts updated to point at the new locations.
2026-04-20 11:38:46 -04:00
ZacharyZhang-NY 5a41489bc4 Pin local hook signatures to pi-agent-core compile-time contracts (C5)
Adds src/main/worker/pi-hook-contract.ts, a compile-only contract that re-states every pi-agent-core hook callback shape (beforeToolCall / afterToolCall / transformContext) as a named alias and pins the local worker implementations against them via type-level ExpectHookType assertions. pi-agent-factory.ts imports the contract for side effects so tsc drags it into the build graph. A future pi upgrade that renames a field or changes an argument now fails typecheck here instead of at first destructive tool invocation.
2026-04-20 11:35:04 -04:00
ZacharyZhang-NY 834ac913ca Chain CEO runs through self-wake during team building
triggerSelfWake fired only when the completing agent had at least one
actionable task (todo / in_progress). A CEO in team_building mode has
zero tasks by definition — each heartbeat hired one or two agents,
returned, and then waited for the next 2-minute timer tick. Operators
watched bootstrap crawl: in the captured field log, six heartbeats
burned 12 minutes before the first hire landed.

Teach triggerSelfWake to also chain when the agent is a top-level CEO
with active goals, zero active direct reports, and no already-pending
hire_agent approvals — the exact state team_building.en.md is telling
the model to resolve. The existing circuit breaker (MAX_CONSECUTIVE_WAKES
without progress) still applies, so a CEO that keeps returning without
filing hires gets paused and its manager wake — the board / human —
is raised instead.

Once reports activate or all intended hires are filed, the predicate
flips to false and runs resume the normal heartbeat cadence.
2026-04-19 21:10:28 -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 271c6cfec8 Bind communication wakes to concrete inbox rows
Replace the worker's generic `"Continue."` default with a dispatch-time
`initialUserTurn` that is resolved per trigger. Message and peer-request
wakes now enumerate the actual unread messages (id, sender, channel,
subject, reply-to) so the agent's first user turn carries the entities
it is expected to act on, instead of idling into a status summary.

Drop the system-prompt `heartbeatFallbackSection`: its "respond with a
brief status summary" line fought the wake-reason block above it and
was the main reason communication wakes degraded into self-narration.
The wake-reason section plus the bound initial user turn now cover
every case that section was carrying.
2026-04-19 14:43:57 -04:00
ZacharyZhang-NY e917c614c9 Gate inbox delivery on worker-side steer ACK
Replace the fire-and-forget steer with a correlated ACK round-trip so main
only marks an inbox row delivered once the worker confirms the message
landed on the live Agent. Steer against a run that is still in the
`preparing` window or has already started `closing` now falls back to the
wake + inbox projection path instead of being silently dropped.

Worker publishes `run-agent-state` transitions at Agent construction and
teardown; main tracks these on `ActiveRunContext.state` and refuses to
steer runs that are not `agent_active`. A rejected or timed-out ACK
triggers `wakeAgentIfPossible` so the next `prepareRun` replays the row.
2026-04-19 14:38:04 -04:00
ZacharyZhang-NY 76c2f085b6 fix(session): evaluate rotation against post-accumulation totals
`accumulateAndRotateSession` called `evaluateRotation` on the
pre-accumulation `session` object, then re-read the row only
inside the shouldRotate branch. A run that tipped the token
threshold on its own usage would read "below threshold" on this
cycle and wait a full additional run before rotating — long
enough for the transcript to keep growing past the point where
rotation was supposed to protect the context window.

Read once after accumulation, evaluate against the fresh totals,
rotate from the same object. No new queries, no new branches — the
failure mode is just an off-by-one-run bug in the original
read-eval-rotate sequence.
2026-04-19 01:03:44 -04:00
ZacharyZhang-NY e1693899a2 feat(runtime): wire pi-agent-core hooks end-to-end
The worker previously constructed `new Agent({systemPrompt, model, tools,
messages, streamFn})` and stopped there — transformContext,
beforeToolCall, afterToolCall, sessionId, thinkingLevel, and steer/
followUp were all dark. Approval enforcement, artifact projection,
auto-compaction, and mid-run steering all had to reach the agent
through other, non-canonical channels (event observers, prompt-block
injection at prepareRun, API-layer gates). That left the whole pi
contract at one-tenth utilisation and forced business logic into the
wrong seams.

This change rebuilds the contract assembly layer:

  - `pi-agent-factory` builds the Agent with the full option surface:
    sessionId (taskSessionId), thinkingLevel (mapped from
    agent.metadataJson), and three RPC-proxied hooks.
  - Each hook shuttles a thin payload over the existing worker RPC
    channel into `RuntimeBridge`, which routes to `hook-handlers`:
      * beforeToolCall → approval-gate policy (blocks destructive
        tools while a pending approval is outstanding).
      * afterToolCall  → filesystem artifact projection, previously
        nested in the tool_execution_end event observer.
      * transformContext → pass-through today; compaction wires in
        next commit.
  - ParentMessage gains `steer-run` / `followup-run`; `WorkerRunner`
    keeps a runId→Agent registry so main can inject mid-run messages
    into the active pi loop.

`worker-types` extends the rpc-request method enum; `runtime-bridge`
loses the per-event artifact projector and gains a `findActiveRunForAgent`
lookup used by the upcoming inbox → steer path. No public main API
changes yet — the wiring is the foundation for the next five commits.
2026-04-19 00:48:13 -04:00
ZacharyZhang-NY f041336721 refactor: nuke dead exports, dead files, dead DB column, dead zero-value wrappers
Full-codebase ts-prune + manual audit sweep. Every change below is a pure
deletion (or a 2-line re-wire where a dead function was supposed to be
alive). No scope additions.

Dead wrappers / phantom features
  - shouldAutoApproveApproval: zero-value wrapper over shouldAutoApprove.
    Four call sites now import shouldAutoApprove from execution-policy
    directly.
  - evaluateRotation(session, connectorId, thresholdsOverride): the
    connectorId arg was never read (underscored), and thresholdsOverride
    was only exercised by the test suite — no production caller. Signature
    collapses to evaluateRotation(session). DEFAULT_THRESHOLDS /
    RotationThresholds exports and their tests go with it.
  - installLogRedactor: defensive console wrapper that was never wired
    from worker-entry. buildRedactor handles the known threat (env-value
    leaks); the pattern-based redactTokens stays for tests and future
    composition.

Dead files
  - resume-interrupted-tasks.ts: helper function never imported.
  - service-graph-orchestration-deps.ts: three factory closures
    superseded by the inline closures in create-orchestration-service.

Dead exports
  - listConnectorsByAuthKind (shared/connector-catalog)
  - getLocalePromptInstruction + whole localization.ts file
  - ImportantDocType
  - main-side ConnectorAuthKind (duplicated the shared one; not referenced)
  - CheckpointEngineService, RunHousekeepingService types
  - WorkspaceResolution type
  - cmdOrCtrlSymbol
  - AgentEventPush, CredentialPayload type aliases
  - parseApprovalPayloadJson, parseEnvValue, ProviderCard
  - getSOP — buildSOPSection reads the SOPS map directly; the individual
    getter had no callers. Deduped to a flat array.
  - renderWakeReason in wake-reasons/en.ts: byte-for-byte duplicate of the
    one in wake-reasons/index.ts.

Forgotten wiring (not cleanup, but adjacent)
  - registerAgentStandupRoute: the GET /api/agents/me/standup endpoint is
    advertised in every system prompt and wake-reason doc, but the route
    handler was never registered in api-server/index.ts. Agents have been
    hitting 404 since Pi Mono. Registered.

Dead DB column
  - v44 migration drops runs.context_snapshot_json. Column was added by
    v16-v25, never written or read, and v43's deliberate preservation
    was an interim measure. Same idempotent table-rebuild pattern as
    v41/v43.

Test tree
  - Phantom-feature threshold override tests removed (3). The rest of
    the rotation suite re-expressed against the real maxRuns=20 floor.

495/495 unit tests pass. typecheck + build clean.
2026-04-18 23:37:37 -04:00
ZacharyZhang-NY e0e43d6127 refactor(session): nuke dead sessionParams / sessionDisplayId pipeline (v43)
These columns and the messages that plumbed them existed for the CLI-
adapter era — Gemini's resume id and provider-specific session params
rode the run-finished envelope back onto the session row so the next
turn could resume. Pi Mono dispatches every provider through pi-ai and
emits neither value. The worker has been posting literal null on every
finish, finalizeRun has been writing null into task_sessions, and
rotateSession has been carrying null forward.

Remove the wire, the writer, the carry-forward, and the columns:

  - v43 migration rebuilds task_sessions without session_params_json /
    session_display_id, and runs without session_display_id. Follows
    the idempotent "create new, copy, swap, reindex" pattern used by
    v41. context_snapshot_json is preserved (also unreferenced today
    but outside this cleanup's scope).
  - RunJob / EnqueueRunPayload / ChildMessage / worker-types zod
    schema no longer carry the fields. finishRun() / createNewSession()
    signatures tighten in lockstep.
  - rotateSession starts the new session with fresh counters and no
    carry-forward.
  - turn-context resolveSession returns just the session id.
  - TaskSessionRecord / RunRecord lose the dead fields.

498/498 unit tests pass. typecheck + build clean.
2026-04-18 23:22:17 -04:00
ZacharyZhang-NY 88a1f8b334 refactor(connectors): drop dead CLI command field after pi-mono migration
The CLI-adapter era `connector.command` field became vestigial once every
provider dispatch went through pi-ai. The schema still required a non-empty
command, so onboarding saveConnector silently rejected every Pi Mono row
(which carries an empty default). Remove the field from the payload chain
(renderer → IPC schema → DB CRUD → run dispatch → worker job), narrow
RunJob.taskId to match EnqueueRunPayload, and drop the dead helper plus
its test. Schema column stays NOT NULL for DB compatibility; seedConnectors
writes a literal empty string so no migration is needed.
2026-04-18 23:09:59 -04:00
ZacharyZhang-NY 2ace9183b0 refactor(session): drop dead task-scoped session columns (v41)
Sessions have been agent-scoped since v34, but the schema kept four
dead task-scoped columns that every session code path had to pretend
were meaningful:

  - tasks.active_session_id     (mirrored agents.session_id)
  - tasks.session_json          (superseded by task_sessions.session_params_json)
  - tasks.session_display_id    (superseded by task_sessions.session_display_id)
  - task_sessions.task_id       (always empty-string on new rows)

v41 drops all four via SQLite table rebuild — the only safe path when
multiple columns need to go in one step and indexes / FK directions
need to be preserved. `PRAGMA foreign_keys = OFF` spans the swap
window so cascade-delete does not fire mid-rebuild. The migration is
idempotent and also backfills the optional task columns
(task_type / requires_user_review / retry_count / last_failure_at /
started_at / completed_at / cancelled_at / checkout_run_id /
execution_locked_at) before rebuilding so it works whether or not
`ensureOptionalColumns` has run yet on this install.

Along with the schema change, every code path that read or wrote
those columns has been rewired to the agent-scoped equivalents:

- task-sessions-crud.ts: deleted updateTaskSession,
  getTaskSession, getActiveTaskSession, updateTasksActiveSessionForAgent;
  added updateSessionParams(sessionId, ...) for the worker's
  finalizer. ensureAgentSession and createNewSession no longer touch
  task_id.
- database-base.ts: facade drops the now-unused pass-throughs.
- snapshot.ts / task-queries.ts / tasks-status.ts / entity-crud.ts:
  SELECT projections no longer pull session_json / session_display_id
  / active_session_id from tasks.
- tasks-crud.ts (TaskRow) and shared/types/records-core.ts
  (TaskRecord): sessionJson / sessionDisplayId fields removed.
- TaskSessionRecord: taskId field removed.
- turn-context.ts::resolveSession: no more
  updateTasksActiveSessionForAgent mirror; sessionParams read directly
  from the current session via getSessionById.
- run-finished-helpers.ts::finalizeRun: stamps sessionParams via
  updateSessionParams on the agent's current session, resolved through
  agents.session_id.
- session-management-service.ts: rotateSession stops calling
  updateTasksActiveSessionForAgent.

Test suite updated to use the new session-id API. Existing tests used
`getActiveTaskSession(taskId)` and `createNewSession({ taskId, ...})`
as probes; they now use `getSessionById(sessionId)` and the taskId-less
createNewSession input.

Tests: 603/603 green. Typecheck clean. Migration tested on fresh DB
(runs v1→v41 cleanly) and idempotent re-apply (detects absent dead
columns, skips).
2026-04-18 21:54:31 -04:00
ZacharyZhang-NY ff5e22092a refactor(session): agent-scope rotation + accumulation; auto-register file artifacts
Sessions are already agent-scoped post-v34, but the code kept routing
rotation / accumulation / handoff through the task indirection. Heartbeat
runs (no task) silently skipped all three, letting taskless transcripts
grow past the model's context window — the 7M-token explosion.

- DB adds getSessionById(sessionId) — direct lookup bypassing
  tasks.active_session_id
- session-management-service: generateHandoff(session) is now agent-scoped,
  walking recent runs across tasks + heartbeats; getHandoffForAgent(agentId)
  is the canonical lookup, getHandoffContext(taskId) is a thin shim for
  callers that still hold a task id
- turn-context.ts::resolveSession drops the `if (!task)` early return:
  rotation evaluation runs on the agent session unconditionally
- run-finished-helpers.ts::accumulateAndRotateSession drops the `!task`
  gate: tokens accumulate and rotation fires for every run, keyed on the
  agent session, not the task
- Rotation evaluator contract: evaluatePreRunSessionRotation(agentId,
  connectorId) → { rotated, handoff, sessionId }; old (taskId, connectorId)
  signature removed

IPC contract tightening: ipc-support::emitEvent becomes a best-effort
sink. "Render frame was disposed" during renderer reload is a lifecycle
signal, not a logic error; silently drop it instead of letting it abort
downstream event emissions.

Artifact projection: pi's write/edit tool results now upsert a
`work_products` row via a new agent-runtime/artifact-projector.ts. No
more agents writing 54 files into the workspace that the UI never sees.
Migration v40 relaxes work_products.task_id to nullable so heartbeat
artifacts can register too.

File size: split agent-event-projection.ts out of runtime-bridge.ts to
keep the bridge under the 400-line cap after adding the projector hook.

Tests: 604 unit + integration green; generateHandoff callsites updated
for the single-argument signature; artifact-projector has pure-function
coverage via the projector module's own shape.
2026-04-18 21:37:56 -04:00
ZacharyZhang-NY 02ba887610 fix(runtime): stop gating pi tool calls; Co-Founder approvals live at API layer
The Co-Founder beforeToolCall hook (introduced in 77f81a7) gated every
non-readonly pi tool call as a `dangerous_command` approval. Because
agents call `POST /api/companies/:id/messages`, `/agents` (hire), and
every other company mutation via bash→node/curl, the first bash call in
every Co-Founder run got parked on a 30-minute approval timeout. Users
observed it as "agents never talk to each other" and "every bash command
waits for approval"; both symptoms are the same bug.

The business-level approval flow (`hire_agent`, `document_review`,
`plan_review`, etc. in execution-policy.ts) already gates the events
users actually care about, at the HTTP route layer where the event
originates (routes-agents.ts:368 for hiring, document routes for
deliverables). Stacking a pi-level hook on top was duplicate gating with
the wrong taxonomy.

Removed:
- src/main/agent-runtime/approval-hook.ts (entire file, 137 LOC)
- `beforeToolCall` conditional in worker-runner.ts and the
  corresponding RPC method in worker-types.ts and runtime-bridge.ts
- `approvalHook` field + `resolveApproval` callback + shutdown
  `cancelAll` in runtime-bridge.ts / service-graph-pi-runtime.ts /
  wire-api-server.ts

Unchanged:
- `dangerous_command` approval type itself (still used by automation
  workflows and routes-misc.ts)
- CEO mode (already never attached the hook)
- All business-level approval types and their wiring
2026-04-17 19:11:04 -04:00
ZacharyZhang-NY 7e1d4199ee refactor(schema): make runs.task_id nullable, delete heartbeat workaround
The NOT NULL constraint forced heartbeat runs to synthesize a fake task
row (`[Heartbeat] X — Check for work`) so the FK could resolve. Three
layers of code — acquireHeartbeatTask, findReusableHeartbeatTask,
cleanupDuplicateHeartbeatTasks, plus the is_heartbeat flag column + UI
filter — existed to paper over one bad constraint. Remove the
constraint and the whole workaround vanishes.

SQLite table rebuild with data preservation. Live production DB, no
backup — migration is atomic, idempotent, and verified before DROP.

## Migration v35 (rebuild runs with nullable task_id)

Uses `fn:` form so it can:
- Snapshot `COUNT(*) FROM runs` before copy
- Walk `PRAGMA table_info(runs)` to detect drift columns (v34 declared
  a column set; any additive ALTER since v14 shows up here)
- Assert `COUNT(*) FROM runs_new == beforeCount` before `DROP TABLE runs`
- `PRAGMA defer_foreign_keys = ON` so run_events(run_id) FK defers to
  commit time, by which runs has the same id set under the new name
- Recreate all 6 indexes on the new table

If the assertion fails, the migration throws inside the existing
`transaction()` wrapper. SQLite rolls back the whole migration, old
`runs` table is untouched, `schema_migrations` never records v35. Next
app start retries cleanly.

## Migration v36 (drop tasks.is_heartbeat + synthetic rows)

Runs now carry `task_id = NULL` for heartbeat dispatches. The
`is_heartbeat` flag is dead data going forward.

1. `UPDATE runs SET task_id = NULL WHERE task_id IN (SELECT id FROM tasks WHERE is_heartbeat = 1)`
2. `DELETE FROM tasks WHERE is_heartbeat = 1` (synthetic rows, not real work)
3. Rebuild `tasks` without the `is_heartbeat` column (same defer-FK +
   row-count-assert pattern as v35)

## Code cleanup

- `RunRecord.taskId: string | null`; `createRun` / `cancelRunForDeletion`
  / `finishRun` guard every `UPDATE tasks` path with a null-check.
- `TaskRecord.isHeartbeat` removed; every SELECT / filter / UI reference
  dropped.
- Delete `acquireHeartbeatTask` / `findReusableHeartbeatTask` /
  `cleanupDuplicateHeartbeatTasks` — dead after the constraint lifts.
- Delete `src/main/orchestration/heartbeat-task.ts` (pure workaround
  constant file).
- `heartbeat.ts` creates runs with `taskId: null` when the agent has no
  assigned work. Turn context already supports nullable task
  (heartbeatFallbackSection); prompt surface unchanged.
- `validateTaskCreation` drops the `isHeartbeat` parameter — user-
  authored + automation-authored tasks are never heartbeat.
- Renderer: `run.taskId ? taskMap.get(...) : undefined` in Dashboard /
  RunCenter / RunDetail / RunQueue.
- Delete `tests/unit/heartbeat-coalescing.test.ts` (12 tests that
  exercised the deleted DB methods).
- Drop 1 isHeartbeat-bypass test from `task-quality-gates.test.ts`.

## Grep verification

- `isHeartbeatTaskTitle src/` → 0
- `acquireHeartbeatTask src/` → migration comment only
- `is_heartbeat src/` → schema.ts migration history only
- `isHeartbeat src/` → 0

Tests: 578/578 (was 591 — delta is 13 tests that tested deleted code).
2026-04-17 16:16:08 -04:00
ZacharyZhang-NY ecd1bc2e33 refactor(worker): single active-run map replaces parallel claim structures
Worker scheduling invariants ("one agent at a time", "one exclusive
workspace lock at a time") were enforced across three parallel
structures: `activeByAgent: Map<string, string>`,
`activeByWorkspace: Map<string, string>`, and `queue: RunJob[]`. Every
claim, release, and tick had to update them in lockstep, with defensive
comments explaining the ordering to future readers.

Replace with one `active: Map<runId, ActiveRun>` where ActiveRun
carries agentId + workspaceKey. Busy checks iterate the map (≤ dozens
of entries); dispatch is a single set + splice. Release is a single
delete. No parallel structures to keep in sync.

- Add `isAgentBusy(agentId)` and `isWorkspaceBusy(lockKey)` as local
  helpers — both O(n) over the active map, which is fine at this scale.
- `tickQueue` is claim-before-dispatch: writes `active.set` synchronously
  before `startRun` so no async window exists between claim and
  dispatch.
- Drop the `lockKey` parameter threading through `startRun` — the map
  carries it.
2026-04-17 15:07:27 -04:00
ZacharyZhang-NY 14dc9a4897 refactor(cost): single writer for cost_entries, finishRun reads SUM
Cost was written through three independent paths: worker-runner shipped
`finalUsage.costUsd` in run-finished -> finishRun wrote `runs.cost_usd`,
`tasks.cost_usd += costUsd`, `agents.spent_monthly_usd += costUsd`, and
inserted a per-run aggregate row in cost_entries. CostWriter
additionally inserted per-message rows in cost_entries from main. Any
CostWriter failure created silent drift between `SUM(cost_entries)`
(read by budget enforcement) and `runs.cost_usd` (read by the UI).

Make CostWriter the single writer, finishRun the single reader.

- Add `db.recordCostEntry(...)` as the first-class insert path for
  per-message cost rows. CostWriter calls it; tests call it directly.
- Remove the aggregate INSERT from finishRun. finishRun now reads
  `SELECT COALESCE(SUM(amount_usd), 0) FROM cost_entries WHERE run_id=?
  AND unattributed=0` and writes that as the authoritative run total to
  runs.cost_usd, tasks.cost_usd, and agents.spent_monthly_usd.
- Drop `costUsd` from `finishRun` input, the worker `run-finished`
  ChildMessage shape, the automation `RunFinishedEvent` type, and every
  caller + test fixture. Cost is owned by the per-message path.
- worker-runner stops extracting `usage.cost.total` — token counts stay
  for session accumulation, cost comes from pi-ai via CostWriter.
- run-finished-helpers now gates budget incident checks on
  `event.status === "succeeded"` instead of a cost field that no longer
  exists on the event.
- automation-run-completion fires `budget_threshold` on every succeeded
  run (post-run spent_monthly_usd already reflects the SUM path).
- Update budget tests to write a `recordCostEntry` before `finishRun`
  to simulate the per-message cost path; drop `costUsd` from cross-
  subsystem, edge-case-budget, database, and related fixtures.

One writer, one reader, one source of truth. No drift.
2026-04-17 15:04:22 -04:00
ZacharyZhang-NY 77f81a7c5a fix(runtime): revive AI Co-Founder tool-call gating via pi beforeToolCall
An earlier cleanup deleted HumanApprovalHook wholesale on the rationale
that the body always returned {}. That was the stub implementation, not
the feature: 版本更新.md specifies AI Co-Founder mode as "human-reviewed
execution that pauses until human review arrives". With no beforeToolCall
attached, Co-Founder and CEO modes behaved identically — a silent
regression of the product's core differentiator.

Bring the hook back with real gating logic and split the Agent
construction path by execution mode so CEO runs pay zero IPC overhead.

- HumanApprovalHook.before(): create a `dangerous_command` approval row
  for non-whitelisted tool calls, emit approval-updated, return a promise
  that resolves when a human decides.
- AUTO_APPROVED_TOOLS whitelist: read/ls/glob/grep/find/tree. Anything
  else (bash, write, edit, …) waits for human approval.
- approvalHook.resolveApproval(): invoked from wire-api-server's
  onApprovalResolved *before* automation and thread follow-ups, so the
  pi Agent unblocks the instant the human decides.
- approvalHook.cancelAll(): release every pending wait on shutdown so the
  worker can drain cleanly; wired into service-graph-pi-runtime.release.
- worker-runner: attach beforeToolCall only when job.executionMode ===
  "ai_cofounder". CEO mode constructs Agent with no hook, no RPC hops.
- WorkerRpcClient.invoke: accept per-call timeoutMs override so approval
  RPCs can wait 30min instead of the 30s default.
- worker-types schema: restore beforeToolCall in the method enum.
- RuntimeBridge.handleRpc: restore beforeToolCall branch.

No changes to approval DB schema, approval lifecycle, or existing
automation/thread handling. CEO-mode runs retain zero-IPC-per-tool
behavior from the prior cleanup.
2026-04-17 14:53:22 -04:00
ZacharyZhang-NY 5547c29491 update 2026-04-17 14:03:56 -04:00
ZacharyZhang-NY 806200b3d1 fix(runtime): correct pi AgentEvent projection + add Console diagnostics
karpathy-guidelines pass — never guess at types, read the source.

Root cause of the silent "等待日志输出" Console:

1. runtime-bridge's projectAgentEventToLogChunk switched on event types
   that never exist in pi. Real types per pi-mono-reference/packages/
   agent/src/types.ts `AgentEvent`:
     agent_start / agent_end / turn_start / turn_end
     message_start / message_update / message_end
     tool_execution_start / tool_execution_update / tool_execution_end
   (NOT the `assistant_message_delta` / `tool_call_start` / `error` I
   invented.) Every case therefore fell through to `default → null`,
   so zero log chunks were emitted even when the agent was streaming
   tokens.

   Rewrote the switch to match the real union. Streaming assistant
   text and thinking now route through `message_update` ->
   `assistantMessageEvent.text_delta` / `thinking_delta` (see
   pi-mono-reference/packages/ai/src/types.ts `AssistantMessageEvent`
   for the inner protocol). Tool activity routes through
   `tool_execution_start` / `tool_execution_end`.

2. worker-runner had no Console-visible breadcrumbs between
   `run-status: running` and eventual turn output, so when the agent
   stalled (credential fetch, model resolution, first API call) the
   user saw a bare "RUNNING" with no clue where time was being spent.
   Added 5 targeted `run-log` probes:
     [runner] starting run ...
     [runner] prepareRun ok, resolving model
     [runner] model=... provider=... — constructing Agent
     [pi-event] <type>        (inside agent.subscribe, one per event)
     [runner] calling agent.prompt (N chars)
     [runner] agent.prompt resolved
   These are structured runtime signals, not test/mockup data. They
   surface the exact phase a stuck run is in.

typecheck 0 / lint 0-0 / test:unit 508/508 / build:verify 0-0.
2026-04-17 10:35:23 -04:00
ZacharyZhang-NY e7e43bd0a6 fix(fireworks): route Fireworks models through resolveModel everywhere
pi-ai's getModel/getModels does not know about the Fireworks providers
(fireworks, fireworks-firepass); both live under our local model-resolver
because Fireworks speaks the Anthropic-compat endpoint with Bearer auth
at https://api.fireworks.ai/inference, not pi-ai's built-in catalog.

Three call sites were still hitting pi-ai directly and failing silently
for Fireworks:

1. connector-model-policy.ts — listProviderModels() caught the pi-ai
   throw and returned []. The "Fire Pass (CEO runtime)" card in the
   Team Models onboarding step therefore showed "暂无模型 / 已启用 0/0",
   blocking users on Fire Pass from continuing onboarding.

2. worker/worker-runner.ts — getModel() threw mid-run when the selected
   connector was Fireworks, so the worker could never actually execute
   an agent turn against Fire Pass or the curated Fireworks model set.
   Now uses resolveModel() + the Fireworks-specific Bearer header from
   resolveExtraStreamHeaders() so streamSimple hits the correct endpoint
   with the correct auth.

3. agent-runtime/cost-writer.ts — cost observation silently wrote
   amount_usd=null for Fireworks runs because getModel() threw inside
   the try/catch. Now resolves via resolveModel() so the Fireworks
   curated contextWindow/maxTokens are visible to calculateCost().

Verification: pnpm typecheck 0 / pnpm lint 0-0 / pnpm build:verify 0-0
/ pnpm test:unit 508/508.
2026-04-17 09:51:38 -04:00
ZacharyZhang-NY e94939a562 test(connector-id): migrate fixtures and assertions to pi-mono ids
Replaces legacy adapter ids (codex_local / claude_local / gemini_local /
claude_compatible) across ~42 test files with pi-mono equivalents:
- codex_local → openai-codex
- claude_local → claude-pro
- gemini_local → gemini-cli
- claude_compatible → anthropic

Hand-rewrites where fixture semantics changed:
- connector-matching: TASK_TYPE_PREFERENCE ordering rebased (claude-pro
  first for code/content/management; google first for research)
- connector-model-policy: getModels(provider) from pi-ai instead of the
  deleted adapter.module.models array
- session-management + edge-case-session: adapter exemption removed;
  rotation now uniform under pi-mono
- ipc-core-admin-handlers: CSV compat model replaced with pi-ai anthropic
- connectors.spec: lookup by openai-codex id (catalog now starts with claude-pro)
- onboarding-model-pool: listProviderModels(provider) helper; label text
  changed to "ChatGPT Plus/Pro (Codex)"
- database integration: added vault insecure-fallback env + electron mock
- command-paths: uses new @main/connector-helpers normalizeCommandValue
- pi-mono-migrations: added `as string` casts for 3 negative-existence
  assertions verifying legacy ids were removed

Drops:
- pnpm-workspace.yaml packages: [] (was "packages/*"; directory empty)
- obsolete CONNECTOR_STRENGTHS import; .module.models probing

Verification:
- pnpm typecheck → 0 errors
- pnpm lint → 0 errors / 0 warnings
- pnpm build:verify → 0 errors / 0 warnings
- pnpm test:unit → 508/508 pass
- pnpm test:integration → 96/96 pass
- All 6 adapter hard gates pass (NOT FOUND / 0 matches / empty)
- pi-ai / pi-agent-core / pi-coding-agent all resolved @0.67.6
2026-04-17 02:36:53 -04:00
ZacharyZhang-NY 4de9cd0339 refactor(worker): use pi-mono Agent directly
Rewrites the utility-process worker to drive pi-agent-core's Agent class
against pi-ai models and pi-coding-agent tools. The entry file keeps the
queue and cancellation state, delegates runs to WorkerRunner, and routes
RPC responses through WorkerRpcClient so beforeToolCall/afterToolCall
approvals and prepareRun credential resolution travel back to main's
RuntimeBridge. Worker message schema gains `agent-event` + `rpc-request`
envelopes, and run-finished no longer depends on the deleted sessionCodec.
2026-04-17 02:21:12 -04:00
ZacharyZhang-NY 71791825ce chore(lint): remove unused vars and imports
- agents-crud: drop unused roleLevel local + inferRoleLevel import
- agents-hierarchy: prefix unused builder params with _
- company-settings: drop InboxItem import, prefix unused builder params
- worker-message-handler: drop unused WorkerServiceDependencies import
2026-04-17 01:44:40 -04:00
ZacharyZhang-NY a852597d2e refactor(workspace): extract resolveWorkspaceWithLockMode shared helper
P1-21: worker-crash-recovery reinvented workspace resolution inline and
silently dropped the exclusive/shared distinction — a recovered run
could claim shared mode that the original run held exclusively.
Introduce resolve-workspace.ts as the single source of truth for the
task→agent→project→company fallback chain and its lockMode, used by
both run-dispatch and crash recovery.
2026-04-16 23:00:13 -04:00
ZacharyZhang-NY cc02b45864 refactor(worker): split god-handler into per-message type files
Extract handleWorkerMessage (380 lines, 12 positional args) into dispatcher
+ focused handler modules. Dispatcher now routes by message.type to
run-log / run-status / run-finished handlers, each receiving (event, deps,
context) triple. run-finished is further decomposed into 5 steps:
finalizeRun, accumulateAndRotateSession, emitAndNotifyCompletion,
runPostCompletionAutomation, triggerSelfWake.

No behavior change: all branches, notifications, lease renewals, circuit
breaker, and escalation semantics preserved. Schema validation stays at
dispatcher top level.
2026-04-16 22:04:37 -04:00
ZacharyZhang-NY ff0c01fe87 fix(worker): claim workspace lock in main before enqueue (D-02)
The lock was previously claimed when the worker reported `running`, which
is after `agent.prompt` had already started streaming. Two concurrent
runs targeting the same workspace could both pass the main-side checks,
both get enqueued, and both read half-written state before one of them
lost the lock race and was cancelled mid-flight.

Move the `claimWorkspaceLock` call into `dispatchRunToWorker` so main
coordinates exclusivity authoritatively. If the lock is unavailable,
the run is finished as failed and never enqueued. The worker-side
lease-renewal path is preserved since it is a lease, not a lock.
2026-04-16 21:49:30 -04:00
ZacharyZhang-NY a1f021ff4a fix(worker): invert connector bypass default and gate on packaged build (S-01)
Previously `AGENTCOMPANY_ALLOW_UNSAFE_CONNECTOR_BYPASS !== "false"` meant
packaged builds defaulted to passing `dangerouslyBypassApprovalsAndSandbox`,
`dangerouslySkipPermissions`, `yolo`, and `--sandbox danger-full-access`
to CLI connectors, bypassing ApprovalService entirely.

Require both explicit opt-in (`AGENTCOMPANY_ALLOW_UNSAFE_CONNECTOR_BYPASS=true`)
and a non-packaged build. Packaged builds hard-refuse regardless of the env
var. Main process forks the worker with `AGENTCOMPANY_PACKAGED=<app.isPackaged>`
so the worker has an authoritative packaged signal.
2026-04-16 21:48:10 -04:00
ZacharyZhang-NY f5d4f8b705 update 2026-04-06 00:35:27 -04:00
ZacharyZhang-NY 72157f0703 update 2026-04-05 23:47:56 -04:00
ZacharyZhang-NY 9145572422 update 2026-04-05 20:14:48 -04:00
ZacharyZhang-NY b8dda3c8d0 update 2026-04-04 17:01:26 -04:00
ZacharyZhang-NY 67c757f481 update 2026-04-04 12:03:50 -04:00