Commit Graph
19 Commits
Author SHA1 Message Date
ZacharyZhang-NY aaa31ea5ae Skip baseline walk on git workspaces
`prepareRun` used to call `captureWorkspaceBaseline` on every
workspace with a path, even when `.git` was present. The baseline was
silently discarded by `collectChangedPaths` (the git branch wins) but
the walk still paid for itself — an O(tree) scan on every run start,
capped at 20k files, with the only effect being that the first 20k
entries got captured before the capper truncated the tail.

Collapse the decision into `captureBaselineIfRequired`: the function
short-circuits to null for git workspaces so `git status` stays the
sole source of truth, and only scans the tree when the baseline is
the only source available. The call site in `runtime-bridge` now
passes through without the per-call conditional.
2026-04-19 16:34:38 -04:00
ZacharyZhang-NY d4c6056f5e Diff non-git workspaces via an mtime baseline
`projectBashArtifacts` and `reconcileWorkspaceArtifacts` used to bail
with `not_a_git_repo` whenever the workspace had no `.git`, so bash
output in such workspaces never reached `work_products` and the
DocumentCenter looked empty while files piled up on disk.

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

`collectChangedPaths` is the single change-detector for both the
per-bash projection and the end-of-run reconciliation. It picks `git
status` when a `.git` is present and falls back to a
`diffAgainstBaseline` walk otherwise. Paths already on the ignore list
still never land in `work_products`; the identity stays
`(company, workspace, provider, externalId)`; the upsert still refreshes
`last_touched_by_run_id` so the deliverable signal follows the latest
touching run.
2026-04-19 16:18:25 -04:00
ZacharyZhang-NY a36884360a Key inbound correlation by messageId with monotonic pi-timestamps
`registerInboundInjection` used to index correlations by
`Date.parse(record.createdAt)` — a millisecond-precision wall-clock
value. Two inbox rows that landed in the same millisecond collapsed
onto one Map slot, and the thread-event writer lost one peer's
identity on `message_end`.

Identity now lives on `record.id`. `projectInboundMessage` allocates a
monotonic `piTimestamp` per run (base = `Date.parse(createdAt)`,
bumped to `max(base, lastAllocated + 1)` so it is always unique
within the run) and stamps that value onto the projected pi message.
The correlation registry keeps `byMessageId` as the primary index and
`byPiTimestamp` as a reverse lookup so the `message_end` callback —
which only surfaces the pi-message shape — still resolves back to the
author without depending on wall-clock precision.

The runtime message injector now defers projection to
`RuntimeBridge.projectInboundMessage` so the steered message, the
correlation entry, and the allocated timestamp stay in lockstep.
2026-04-19 15:50:39 -04:00
ZacharyZhang-NY cb0ac485da Close inbox projection under one transaction
The old `prepareRun` projection drifted: append-to-shadow, thread-event,
and `markAgentInboxDelivered` ran as three independent writes, so a
crash between the append and the delivered mark replayed the same
inbox row on the next run and double-populated the transcript.

v46 installs `agent_message_projections` keyed by (message_id,
session_id) as the idempotent gate. The projection closure now wraps
the ledger claim, the shadow append, the thread-event write, and the
delivered-mark inside one `BEGIN IMMEDIATE` transaction (exposed via
`AppDatabase.withTransaction`). `claimProjection` returns false on a
replay, so the whole closure short-circuits — no double transcript,
no double thread event.
2026-04-19 15:46:12 -04:00
ZacharyZhang-NY d9b35d73ab Pin work_products identity to (company, workspace, path)
The natural key used to be (companyId, provider, externalId), so two
workspaces with the same workspace-relative path collided onto one
row. Provenance was also frozen at the first write: `created_by_run_id`
never refreshed, so a run that edited a pre-existing file never
qualified as a deliverable and the DocumentCenter "open" link could
still jump into the first workspace after a repo move.

Migration v45 adds `workspace_id` + `last_touched_by_run_id`, backfills
`workspace_id` via the task/run join, seeds `last_touched_by_run_id =
created_by_run_id`, and installs a partial unique index on
`(company_id, workspace_id, provider, external_id)` scoped to rows
that have a workspace. The artifact projector now records
`workspace_id` on insert and refreshes `last_touched_by_run_id` on
every upsert. The deliverable signal query reads either provenance
column so a run that only edited a pre-existing file still counts.
`resolveWorkProductTarget` prefers the stored `workspace_id` and falls
back to the task/run join for legacy NULL rows.
2026-04-19 15:43:48 -04:00
ZacharyZhang-NY 23a19a8180 Promote artifact registration to a primary run outcome
Per-tool `afterToolCall` projection stays the primary write path but is
now backed by an end-of-run reconciliation sweep on `RuntimeBridge`.
`reconcileWorkspaceArtifacts` re-scans the workspace via `git status`
before the run's resources are released, idempotently upserting any
file the per-tool hook skipped — the exact gap that was leaving the
DocumentCenter empty while the disk filled up.

Projection failures stop being swallowed silently: the hook now logs
a tool-level summary at INFO and throws are routed to ERROR so they are
visible in the desktop log tail. The reconciliation sweep surfaces its
own failures the same way, so an operator can tell the difference
between "no files produced" and "files produced but registration
broken".
2026-04-19 14:55:23 -04:00
ZacharyZhang-NY 1b8e6d384a Write thread events for taskless communication runs
recordThreadEvent used to drop the event whenever `envelope.taskId` was
null, so communication wakes produced nothing in the thread view even
while the agent actively replied to peers. Taskless runs now write into
a per-agent general thread (`ensureAgentCommunicationThread`), so the UI
surfaces peer conversations alongside task activity.

Peer-authored user turns inside the transcript used to be labelled with
the recipient's id — pi erases the sender once the message is projected.
A new `inboundCorrelations` map on `RuntimeBridge` pins each pi-message
timestamp to its source `agent_messages` row at injection time, so the
thread-event writer can recover the real author id and `senderKind` on
`message_end`. prepareRun projections (no `message_end` fires for
priorMessages) write their thread event directly as the projection lands
so they are not silently lost.
2026-04-19 14:51:26 -04:00
ZacharyZhang-NY 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 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 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 40040b3dd2 refactor(errors): surface 57 deferred catch-and-swallow sites via logger
The earlier sweep cleaned the hot path (worker/runtime/automation/api-
server/ipc core — 59 sites). This pass finishes the deferred long tail:
main/* services, database/* CRUD modules, api-server/* routes, ipc-*
handlers, thread-integration, automation helpers, browser, skills.

Default strategy: catch (error) { logger.warn("[module] context:", ...) }
— preserve current behavior, make the failure visible instead of
silently hidden. Widened 3 logger interface signatures to include warn
where callers only passed info/error previously (thread-helpers,
host-environment-service, ipc-domain-common).

Legitimate boundaries left intact: JSON.parse on untrusted input, DNS
resolver no-records paths, explicit safe* helpers (safeJson, parseJson,
parseTriggerDetail), URL-parse predicates, and `<unserialisable>`
fallbacks inside the prompt-builder summarisers. Each kept catch now
either has a reason in context or is a documented safe* helper.

Tests adjusted only where test-constructed no-op loggers needed a warn
stub (message-dispatch, company-mode-switch, ipc-agent-graph-handlers).

With the earlier pass, AgentCompany no longer has silently-swallowing
catch blocks in main-process or renderer business code.
2026-04-17 15:25:13 -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 b50716d4e8 refactor(messaging): project inter-agent messages into pi session transcript
Inter-agent messages previously entered the recipient's context as a
separate prompt block (recentMessages), rebuilt every turn. This double-
charged tokens and kept messages outside pi's native AgentMessage stream.

Now at prepareRun: pull the recipient's unread inbox, project each
AgentMessageRecord into a pi user-role AgentMessage, persist to
SqliteSessionStore, and mark delivered. The recipient reads messages
as natural conversation turns in the transcript.

- db: listAgentInboxUnread + markAgentInboxDelivered (transactional)
- agent-runtime/inbox-projection.ts: AgentMessageRecord -> pi AgentMessage
- runtime-bridge.prepareRun: inject + deliver before loadMessages
- prompt-builder: delete recentMessages context block + buildMessagesSection
- message-normalize: drop unused normalizeMessagesForPrompt
- delete prompt-builder/attachment-resolver.ts (orphan after section removal)

Send path (api-server/routes-messages, ipc, UI) unchanged — inbox table
remains the UI source. Transactional delivery marking prevents duplicate
injection across runs. Failed append -> no mark -> retry on next run.
2026-04-17 14:48: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 683dded501 fix(runtime): autonomous beforeToolCall + project agent-event to Console log
Two coupled user-visible defects were fixed.

1. Every tool call (shell / edit / write / read / ...) triggered a
   synchronous human-approval prompt under BOTH execution modes. The
   worker blocked inside pi's beforeToolCall waiting on a DB approval
   row, so runs never progressed — "等待日志输出" forever.

   The execution-mode contract is now enforced:
   - AI CEO       → fully autonomous, no framework gate.
   - AI Co-Founder → still fully autonomous at the pi layer. Human
     review is a model-driven decision (LLM calls an explicit
     `request_approval` tool when it judges review necessary), not
     a framework-level interrupt.

   HumanApprovalHook collapses to a no-op `before` / `after` /
   `cancelAll`. All machinery that queued `dangerous_command`
   approvals from the worker-side hook is removed. No more starving
   the agent on `node -e` self-call checks.

2. The Console panel only subscribed to `run-log` stdout/stderr
   chunks, so pi's agent-event stream (assistant_message_delta,
   tool_call_start/end, errors, turn_end) produced zero visible
   output. With bug (1) fixed, runs now execute — but the user
   would still see a blank Console.

   runtime-bridge.handleAgentEvent now projects each meaningful
   agent-event into a run-log chunk (assistant_message_delta → raw
   delta, tool_call_start → `[tool:bash] {args}`, tool_call_end →
   `[tool:done|error:name] {result}`, error → stderr). ConsoleView
   picks these up via the existing run-log DesktopEvent pipe with
   no renderer change.

tests/unit/approval-hook.test.ts rewritten to assert the new
autonomous contract (before always returns {}, no approval rows
written, cancelAll is a no-op). Tests 508/508 pass.

typecheck 0 / lint 0-0 / build:verify 0-0.
2026-04-17 10:23:50 -04:00
ZacharyZhang-NY 892476bd36 refactor(runtime): wire pi-runtime services and extend RuntimeBridge
Bootstraps CredentialStore/Resolver, OAuthLoginService, and RuntimeBridge
inside service-graph via the existing pi-runtime factory, and exposes the
bridge to worker-service for agent-event + rpc-request routing. Adds a
`prepareRun` RPC that resolves credentials, loads prior messages, and
registers the active run context in a single call. Session rotation drops
the adapter nativeContextManagement branch; run payloads now carry the
taskSessionId and executionMode the worker needs to drive pi-agent-core.
2026-04-17 02:21:04 -04:00
ZacharyZhang-NY 3edb2fc6c1 refactor(main): extract getOrCreateStore in RuntimeBridge
P3-56: three call sites repeated the same 'lookup → fall back to
new → set if missing' dance for session stores. The duplication hid the
fact that the fallback allocation in the second and third sites could
be wasted when the entry already existed. Extract a single
getOrCreateStore helper that does one map lookup, one conditional
construction, and always returns the cached instance.
2026-04-16 23:27:57 -04:00