Commit Graph
4 Commits
Author SHA1 Message Date
Nicky LeachandPaperclip da9af67415 Persist agent-owned instance secrets through lifecycle results (#16191)
## Thinking Path

> - Paperclip coordinates agents and controls when they can run.
> - Lifecycle plugins prepare and remove resources for each agent.
> - Those resources can require private connection credentials.
> - The current SDK can resolve configured secrets but cannot save these
credentials securely.
> - Ordinary company secrets can be bound to agents and do not provide
the required server-only boundary.
> - This change saves agent-owned instance secrets through the existing
local lifecycle result and native secret service.
> - The tenant can retain runtime credentials through restarts without a
separate encryption system or remote API.

## Linked Issues or Issue Description

**Subsystem affected**

Agent lifecycle, plugin contract, and native secret storage.

**Problem or motivation**

Lifecycle plugins need to save private runtime connection details for
the owning agent. Worker memory loses these details on restart. Plugin
state and company secrets do not provide a private runtime-only
boundary.

**Proposed solution**

Add optional instance-secret changes to lifecycle results. Save them
through the existing secret service and local encrypted provider before
accepting the result. Require company, agent, plugin, phase, operation,
revision, and current unexpired attempt ownership. Supply saved values
through the existing local preparation/resume request.

**Alternatives considered**

A separate encrypted store duplicates the native secret system. Generic
plugin company-secret writes proposed by #5007 do not supply agent
ownership or a server-only secret scope. This change does not add that
general write API.

**Roadmap alignment**

Extends the existing lifecycle contract from #15631. It does not add a
resource provider or select an execution backend.

## What Changed

- Add an instance scope to native secrets with mandatory agent/plugin
ownership, a company-qualified agent foreign key, and key uniqueness per
owner. Existing company and user scopes retain their constraints.
- Reuse native creation, rotation, deletion, secret versions, access
audit, and the local encrypted provider. Keep values out of company
catalogs, normal bindings, agent access, and public agent records.
- Accept local secret patches only after locking and checking the
current lifecycle operation and required plugin selection. Storage
failure or a stale reply cannot complete setup. Expired or replaced
attempt owners cannot overwrite a successor's credentials.
- Keep vault-import conflicts company-scoped. Protect instance-owned
account-home paths during failed device-login cleanup with a private,
fail-closed scan.
- Skip decryption on stop and cleanup so a lost key cannot block
removal. Cascade secret records when their agent or plugin is deleted.
- Log fixed provider diagnostics without raw errors that can contain
credentials. Document the local contract, runtime resolver, and
access-map exclusions. Retain the five latest migration snapshots
through the normal generator pruning.

## Verification

- 239 focused tests passed across agent-instance-secrets, lifecycle,
secrets-service, secrets-routes, plugin-secrets-handler, and
agent-device-login-routes. Used disposable PostgreSQL 18 clusters
through the existing test constructor seam because the bundled binary
cannot start on this macOS host. The temporary harness is outside git.
- Regression tests reproduced the vault-import conflict and missed
cleanup claimant before the fixes; both pass after the fixes.
- New coverage includes encrypted native versions, rotation, restart
recovery, company/agent/plugin isolation, ordinary-read and binding
denial, stale replies, storage failure, safe logging, cleanup without
the master key, and same-name secret isolation, native account-home
mutation guards, lease takeover/expiry, and live agent/plugin deletion
cascades.
- Direct server TypeScript check, DB/shared typechecks, migration
numbering/safety checks, module boundaries, and git diff check passed.
- Full workspace typecheck and build were attempted; both stop at the
missing local cargo toolchain. The full test command was started and
stopped after failures in unchanged native-runtime/workspace suites plus
bundled-PostgreSQL skips; it did not complete. CI must verify the full
toolchain paths.
- Current head `c0f8bfd12477ad509252ecd40e168a6c84dbeb36`: all 55 CI
checks passed, including the previously canceled e2e shard. Greptile
Apex reviewed this exact head at 5/5 with no unresolved threads. GitHub
reports the branch mergeable and clean.
- No live provider probe, production data access, or deployment.
Reviewed the diff for credentials, private references, and run
artifacts.

## Risks

Apply migration 0339_luxuriant_iron_monger.sql before deploying tenant
code. It adds nullable ownership columns, a partial unique index,
foreign keys, and a stricter scope constraint. Existing rows remain
valid. Keep the schema on application rollback. Preserve the existing
secrets master key through deployments and backup restoration.

The lifecycle request/result now has a sensitive, server-only field.
Plugins must never log it or return it to an agent or browser. Values
stay in the tenant database through the existing encryption provider.
Trusted runtime consumers must apply normal execution admission before
resolving them. Older hosts omit instanceSecrets; plugins that require
durable storage must reject those hosts.

## Model Used

OpenAI GPT-6 via Codex, with reasoning, repository inspection, code
editing, and local test execution. The exact serving model identifier
and context window are not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-13 10:13:42 -07:00
DottaandPaperclip d6e6d1c78a Stream OpenCode immediately, scope reasoning to turns, and deduplicate instructions (#15708)
## Thinking Path

> - Paperclip helps people manage AI agents and observe their work.
> - The native runner carries provider output into the task transcript.
> - OpenCode sends incremental text in `message.part.delta` events.
> - The driver ignored those events and waited for a full part snapshot.
Accepted chats also waited after lifecycle verification was complete.
> - This pull request forwards each valid chunk as it arrives and
removes unnecessary isolated startup work.
> - Valid chunks reach Paperclip sooner without changing completion or
permission rules. The matched browser experiment below does not
establish a reliable end-to-end latency improvement.

## Linked Issues or Issue Description

**What happened?**

The native OpenCode driver withheld text until OpenCode sent a full
snapshot. A local delayed-provider probe with the pinned OpenCode
1.18.34 runtime showed a 1,496 ms gap between the first native text
delta and the first Paperclip text delta (1,510 ms from provider text).

**Expected behavior**

Forward valid assistant text and reasoning deltas immediately. Preserve
repeated identical tokens, message roles, turn ownership, and final
response selection.

**Steps to reproduce**

Send an empty text part, then text deltas, and withhold the final full
snapshot. The new fixture requires the consumer to acknowledge its first
text delta before releasing the snapshot. The previous driver cannot
pass this handshake.

**Paperclip version or commit**

Baseline: `011e5669bad61c435a4745fb10812bef70376841`. The patch is
rebased onto current master.

**Deployment mode**

Local native OpenCode 1.18.34 on macOS arm64. Live diagnostics use
`openrouter/deepseek/deepseek-v4-flash-0731`. These are narrow
diagnostics, not full Product E2E qualification.

## What Changed

- Keep the macOS verified OpenCode launch pathname until child exit,
with guarded reuse and cleanup on failed launches. This fixes a
reproduced asynchronous executable-lifetime failure.
- Dispatch accepted queued chats immediately after lifecycle readiness
commits, through normal scheduler admission. Preserve ready state if
dispatch fails.
- Validate native OpenCode model access with the real hello probe
without a redundant catalog scan. Keep legacy adapter discovery
unchanged and isolate unspecified native probe directories.
- Map incremental text and reasoning to the existing canonical event
path, using observed part and message identities.
- Keep additive chunks without IDs distinct. Preserve ID-based event
deduplication.
- Clear current streaming part metadata when a new turn starts. Reject
mismatched session/message identities and unknown parts or fields.
- Disable automatic updates, remote model refreshes, and default plugins
in each isolated OpenCode process. Keep assigned MCP tools and default
model reasoning intact.
- Retain the latest cumulative text/reasoning part while its message
role is unknown, including completion metadata after more than 100
chunks.
- Expose `startTurn({ message, reasoningMode: "disabled" })` for one
OpenCode/OpenRouter turn. Carry the selection in the durable command
through Rust and the proxy. An omitted/default option restores provider
defaults on the next turn, including warm and recovered sessions. There
is no environment-level setting or automatic selection policy.
- Group optional Rust turn inputs in `CodexTurnOptions` and dispatch
through `start_turn_with_options`; retain `start_turn` for defaults.
Future turn settings can be added without another feature-specific
method.
- Expose a native-session capability for per-turn reasoning and reject
explicit unsupported selections before dispatch, including direct ACPX
and Codex backends. Runnerd checks the durable selected model as well as
the provider.
- Remove duplicate agent instructions from the shared OpenCode proxy
task envelope for all users. Send one system copy on every turn,
including prepared and resumed turns, because OpenCode reads the latest
user message’s system field. Preserve task constraints and completion
contracts.
- Document startup and streaming behavior. Add regression coverage for
role ordering, repeated chunks, final snapshot deduplication, and stale
turns.

## Verification

### Lower-load browser retry — 2026-10-10

**All 8/8 trials succeeded. In this batch, median Send → first visible
reply fell from 30.586 s to 12.476 s (59.2% lower).** Four fresh-agent
trials per variant, phase order before/after/after/before with two
trials per phase. Every adjacent-phase pair favored the candidate,
although one difference was only 0.301 s. This is evidence for the
readiness/probe changes on this host, not a production percentile or a
guarantee across providers.

| Phase/trial | Variant | Send → reply (s) | Observed ready → run (s) |
Session open → turn start (s) | Model requests |
|---|---|---:|---:|---:|---:|
| 0/0 | before | 45.042 | 25.337 | 3.629 | 2 |
| 0/1 | before | 13.689 | 0.000 | 2.443 | 2 |
| 1/0 | after | 14.696 | 0.000 | 2.526 | 1 |
| 1/1 | after | 13.389 | 0.000 | 2.470 | 2 |
| 2/0 | after | 10.871 | 0.000 | 2.706 | 2 |
| 2/1 | after | 11.564 | 0.004 | 2.443 | 2 |
| 3/0 | before | 41.582 | 21.518 | 2.376 | 2 |
| 3/1 | before | 19.589 | 9.753 | 2.707 | 1 |

Observed ready-to-run median: **15.636 s → 0 s**; zero means execution
began by the first ready observation, not literally zero latency.
Polling interval is nominally 250 ms plus HTTP time.
Session-open-to-turn-start includes controller handling, not just child
startup. All eight ran within the existing startup timeouts; no timeout
or reasoning policy was changed. Host load at fixture creation stayed
**5.47–8.73 on 18 CPUs**, versus 68.9–100.8 in the prior campaign. No
builds/tests ran concurrently with measurement.

Same merged source **b0a0158729a7913882a0e756fe7e27228d72bf53**, release
runner, rebuilt static UI, OpenCode **1.18.34**, model
**openrouter/deepseek/deepseek-v4-flash-0731**, provider-default
reasoning, and empty isolated workspaces in both variants. Only the
readiness worker and explicit-model verification source were toggled;
the launch-lifetime fix and all proxy streaming changes were held
constant. Thus this does not separately measure the original streaming
changes. One browser per phase, two fresh companies/agents/chats per
phase. Measurement begins at capture-phase Send click and ends on
visible Hi text plus two animation frames. Status/thinking is excluded;
emoji can arrive just after the first text. Successful screenshots
inspected. Each variant had three two-request turns using
`paperclip_finish` and one one-request turn; no status lookup was
observed. Reported usage includes settlement after the first visible
reply.

Runner SHA-256
`57f658c220eecd2370ffd3333cd7e5d06d16bb2798af0cf43c84e01c8207c1c3`;
proxy
`bcc02fda2bf35b38a116cd880841aa6c534f6e0b497a96d97369a1e06dcc72ac`;
rebuilt UI tree
`eee238f2eda628c4e8c3660707986da4f8dd9100c814e26938fce3756df2eae4`
(sorted relative path, NUL, binary per-file SHA-256). Worker/probe
variant source hashes are unchanged from the preserved comparison below.
Measured turn cost **$0.00612574**, plus verification probes not
included in that figure. All eight attempts retained; all source/proxy
files restored and test supervisors exited.

Current commit validation: **53 successful CI checks, 4 skipped, zero
failing; Greptile 5/5 on b0a0158729 with no actionable findings.** The
previous 20 targeted post-merge tests passed. No source edit was made
for this rerun. Earlier failed and inconclusive cohorts below remain
historical evidence and are not pooled with this batch.


### Master update and failure diagnosis — 2026-10-09

Merged `origin/master` at `417db14e7d` into this branch without
conflicts; pushed merge commit
`b0a0158729a7913882a0e756fe7e27228d72bf53`. The incoming changes do not
alter OpenCode process startup or its timeout budgets. Historical
measurements below remain attributed to their original commits.

The four last-campaign failures never issued `turn.start`, so these were
**cold-session admission failures, not model replies failing**. Two
retained `session.open` commands eventually completed after controller
timeout/cleanup had begun. The other two ended with the inner
`thread/start` response timeout, and their diagnostics recorded OpenCode
startup retries. Startup event timestamps are buffered publication times
and must not be interpreted as exact process-spawn times.

On the merged source, two credential-free startup-only checks used the
real pinned OpenCode 1.18.34, empty temporary workspaces, a placeholder
key, and **no model turns**:

- Existing pinned executable: spawn at 1.083 s; failed health
attempt/retry at 11.311 s and 21.914 s; third process healthy at 32.210
s; session opened at **35.168 s**.
- Verified private executable path: snapshot preparation took **0.497
s**; process spawned at 1.588 s; healthy at 9.967 s; session opened at
**11.120 s**.

These are sequential diagnostic samples, not evidence that one
executable path is faster. Host load was **56.5–64.5 on 18 logical
CPUs**. The local health wait permits 10 s per attempt and the driver
retries up to three times; the outer controller's cold admission budget
is 30 s, and the Rust facade also waits 30 s for a response. Thus
retries plus setup/session creation can outlast the caller's budget. The
isolated 35.168 s success demonstrates that a session can eventually
open yet fail the app's admission deadline. Heavy load aggravates the
timing; its exact contribution is not isolated. No timeout was increased
and no new model-latency claim is made.

Merge validation: **20/20 targeted lifecycle-policy and heartbeat
run-completion tests passed**, including temporary PostgreSQL cases. The
first sandbox invocation skipped database cases; the authorized local-DB
rerun executed all 20. The package-manager launcher could not verify its
version via the unreachable registry, so validation used the
already-installed Vitest binary. Full CI/review subsequently passed on
this merge commit; see the October 10 retry above. Older results below
apply to their named commits.


### Follow-up: reproduced macOS launch failure — 2026-10-09

The retry exposed a startup bug rather than only host-load noise. The
macOS proxy deleted the private verified executable path immediately
after asynchronous spawn. A credential-free test of the actual pinned
OpenCode `--version` reproduced a fast SIGKILL and a 15-second timeout
in three immediate-cleanup attempts; three deferred-cleanup controls
succeeded. The timeout was killed by the diagnostic's own deadline. This
experiment does not establish the OS-internal reason for the fast
SIGKILL.

Commit `c30451e0ebf3f74e9ef70d37bbe4d9fe7fb56359` retains the private
launch path until child exit, cleans failed spawns, and waits for forced
shutdown before reuse. Source descriptor authentication, ownership,
mode, inode checks, and the prohibition on ambient executable fallback
remain. Overlapping reuse fails closed. The existing synchronous shell
test was changed to exercise the actual asynchronous boundary.

**Verification:** 59 focused proxy/driver tests passed; runner
TypeScript check passed. All six subsequent real OpenCode version
launches succeeded without timeout (4.11, 4.33, 5.73, 6.91, 4.95, 6.70
seconds). The native Rust binary is unchanged from the retry; no model
calls were made by these diagnostics.

Retained interrupted retry: before 42.56/18.69 seconds; after one
180-second timeout (`provider_process_exited` / health SIGKILL) and one
37.33-second success. The latter overlapped the credential-free
diagnostic and is not a clean matched timing. The next phase was stopped
before Send to diagnose the reproduced bug. Reported measured-turn cost:
$0.0016874, verification probes additional. These attempts are not
pooled with other campaigns.

<!-- launch-fixed-proof:start -->
**Completed retry, stopped by the declared failure rule after 6 of 8
planned trials. No reliable end-to-end speedup demonstrated.** At commit
`c30451e0ebf3f74e9ef70d37bbe4d9fe7fb56359`, the launch fix above was
held constant in both variants; only the readiness handoff and
explicit-model verification path changed. Planned phase order:
before/after/after/before, two fresh agents/chats per phase, one new
browser per phase. The second after phase had two consecutive failures,
so the final before phase was not run.

| Phase/trial | Variant | Send → first visible reply | Observed ready →
run | Outcome |
|---|---|---|---|---|
| 0/0 | before | No reply within 180 s | 2.777 s | session.open timeout
|
| 0/1 | before | 47.654 s | 9.383 s | Succeeded |
| 1/0 | after | 50.051 s | 0.087 s | Succeeded |
| 1/1 | after | No reply within 180 s | 0.151 s | session.open timeout |
| 2/0 | after | No reply within 180 s | 3.337 s | session.open timeout |
| 2/1 | after | No reply within 180 s | 1.434 s | session.open timeout |

Both successful screenshots show “Hi 👋”. Timing starts at the browser's
capture-phase Send click and ends at the first visible answer text plus
two animation frames; the first fragment can be “Hi” before the emoji
arrives. Thinking/status text is not counted. All four failures were
`runner_local_connect_failed: PRP command session.open timed out`
(`native_session_interrupted`), before a reply; none reported the
earlier health-stage SIGKILL. They are retained, not discarded as
outliers. One success per variant does not support a speedup estimate or
median comparison. Observed ready-to-run median was **6.080 s before
(n=2) vs 0.793 s after (n=4)**; this is sampled every nominal 250 ms
plus HTTP latency and is a lower bound, not precise tracing. Host load
at fixture creation ranged **68.9–100.8 on 18 logical CPUs**, a
substantial confound that worsened over the campaign. No local
build/test was run concurrently with this campaign.

Provenance: pinned OpenCode **1.18.34**, model
**openrouter/deepseek/deepseek-v4-flash-0731**, default reasoning in
both variants, empty synthetic workspaces and isolated probe homes.
Release runner SHA-256
`57f658c220eecd2370ffd3333cd7e5d06d16bb2798af0cf43c84e01c8207c1c3`;
current proxy SHA-256
`bcc02fda2bf35b38a116cd880841aa6c534f6e0b497a96d97369a1e06dcc72ac`;
built UI tree SHA-256
`7f0c2b7f586cb753408351574b2239e22311944add99af4f6f3562cd6d47bbcb`. This
holds the rebuilt runner and static UI constant. Provider-reported
measured turn cost **$0.00120649**; verification probes are additional.
Including the earlier interrupted retry, this follow-up reports
**$0.00289389** in measured turn usage; missing usage is not assumed
free. Prior cohorts remain separate below.

Validation on `c30451e0e`: **59 focused tests passed**, runner
TypeScript check passed, **6/6 real credential-free pinned OpenCode
launches passed**, fresh Greptile **5/5 with zero unresolved threads**.
CI: **51 success, 4 skipped, 1 failed**. The failed native compilation
job never reached compilation: Docker's auth endpoint timed out during
Buildx setup, then failed the same way on one retry
([job](https://github.com/paperclipai/paperclip/actions/runs/37994181674/job/114039737503)).
Full local checks were not rerun while measuring. The PR remains draft:
the launch defect is reproduced and fixed, but cold session startup
still exceeds the controller's admission budget on this host. Raising
that budget would not establish a faster first response.
<!-- launch-fixed-proof:end -->

### Readiness investigation and live rerun — 2026-10-09

**Measured queue fix; no reliable end-to-end speedup claim. Keep this PR
in draft.** Accepted chats could wait for the periodic queue sweep after
verification had already committed `ready`. The lifecycle worker now
dispatches through the existing scheduler immediately after that commit.
The normal company, pause, budget, concurrency, and task-ownership
checks still apply. Dispatch failure preserves readiness and leaves
recovery to the queue sweep.

Native OpenCode verification also avoids enumerating the full model
catalog before running its real hello request. Identifier validation
remains. Authentication failures, unavailable models, and timeouts still
prevent readiness. An unspecified local probe directory uses an empty
temporary directory.

**Queue-only experiment:** four before and four after attempts, in
before/after/after/before server phases, two fresh agents per phase.
Median observed ready-to-start delay fell from **19.429 s to 0.058 s**.
State polling was nominally 250 ms plus HTTP time, so the latter means
within observation resolution; it is not a precise 58 ms latency claim.
After timings were 100.20, 65.29, 63.78, and 76.97 seconds. Before had
one 59.65-second success and three 120-second response timeouts (one
provider health SIGKILL, one late success, one still running after
settlement and canceled). These censored results do not support a
success-only median speedup comparison. Focused tests overlapped the
last baseline phase, which further limits total-time interpretation.
Reported measured-turn cost: $0.0055383; verification probes and
incomplete/canceled usage are additional.

**Final matched experiment:** built static UI in both variants to avoid
pre-Send Vite compilation; current proxy, release runner, selected
model, default reasoning, and explicitly isolated empty probe
cwd/HOME/XDG held constant. Both worker handoff and model-probe changes
vary. Fresh browser/company/agent/chat per attempt. ABBA order, two
attempts per variant, 180-second response limit declared before the
batch, four-turn bound. Same Send-to-rendered-Hi metric as the earlier
experiment.

| Order | Variant | Send → visible Hi | Created → execution | Observed
ready → execution | Outcome |
| --- | --- | ---: | ---: | ---: | --- |
| 1 | Before | 62.66 s | 23.467 s | 2.830 s | Succeeded |
| 2 | After | >180 s | 14.200 s | Within polling resolution | Provider
health SIGKILL |
| 3 | After | >180 s | 13.877 s | Within polling resolution | Provider
initialization timeout |
| 4 | Before | >180 s | 144.256 s | 14.067 s | Still running after
settlement; canceled |

Only one of four attempts produced a timed reply. **There is no valid
overall speedup estimate from this batch.** The dispatch delay is
removed, but provider startup failures and heavy shared-host load
remain. A recorded host sample was load 93.55 on 18 logical CPUs. This
is a confound, not proof that every failure was caused by host load.
Provider-reported measured-turn cost was $0.00032752, with no reported
usage for the other three attempts; verification probes are additional.

Retained earlier setup attempts: one shared-memory startup failure
before model calls, and a separate development-UI batch with a
60.06-second baseline followed by a navigation timeout before Send.
These attempts are retained separately, not counted as successful
response timings. No live attempts were silently dropped.

Provenance: source base `53580f8fbd40b963f3fb7ba5172ff3ede2e9ef1f`,
measured patch committed as `40b54eafb`, then rebased as
`c590770dcc2bb1debce14037935a7031e5c3c1ac`. **These live timings predate
the rebase.** Source and binary hashes are retained below. The inner
harness label `after` selects the identical current proxy in every
attempt; the outer campaign handoff label is the actual before/after
variant. Owned benchmark processes stopped and original proxy restored.

<!-- readiness-check-status:start -->
Post-rebase validation at `c590770dcc2bb1debce14037935a7031e5c3c1ac`:

- Full local `pnpm -r typecheck`: **passed**, including the native
release build (1,098.6 seconds on the loaded host).
- Focused lifecycle and adapter tests: **26 passed**. The 14 database
integration tests did not run locally because their hard-coded 20-second
database setup hook timed out. A retry with a CLI timeout override
encountered the same explicit hook limit. The earlier pre-rebase focused
batch passed 77 tests; these results are not substituted for post-rebase
execution.
- Local `pnpm test:run` was started, then stopped to avoid duplicating
CI on the saturated host. Local `pnpm build` was not run separately.
**Current-head CI passed Typecheck + Release Registry, Build, all
general and serialized test groups, Rust checks, both runner Vitest
groups, and all eight browser E2E shards.** The last general shard
passed 1,179 tests (72 files).
- Fresh [Greptile
review](https://github.com/paperclipai/paperclip/pull/15708#issuecomment-6085742217):
**5/5**, reviewed this exact head, no actionable defect.
- Final CI rollup: **49 success, 4 skipped, 4 failure**. The failures
are [Compile isolated native
Runner](https://github.com/paperclipai/paperclip/actions/runs/37990274515/job/114024329242),
[Docker context
integrity](https://github.com/paperclipai/paperclip/actions/runs/37990274623/job/114024196511),
[Canary Dry
Run](https://github.com/paperclipai/paperclip/actions/runs/37990274623/job/114024196424),
and the dependent `ci / verify` aggregate. All three root failures are
Docker Hub HTTP 429 / unauthenticated pull-rate limits while fetching
Node base images. The native-image job was retried once and hit the same
limit. No source compiler/test failure was reported by those gates. CI
maintainers need authenticated pulls or to rerun after the registry
limit clears.
- Owned benchmark servers stopped. Local validation commands completed
or were stopped. The PR remains a draft: user-visible speed/reliability
is not qualified, and the Docker-dependent CI gates are not green.
<!-- readiness-check-status:end -->

<details><summary>Sanitized readiness evidence with all attempts and
source hashes</summary>

```json
{"campaign":{"order":["before","after","after","before"],"trialsPerPhase":1,"maxMeasuredTurns":4,"maxCampaignUsd":1,"sourceHead":"53580f8fbd40b963f3fb7ba5172ff3ede2e9ef1f","reasoning":"provider default, no override","proxySha256":"6691475ed55f10179ec6ee0023a1f6462982f763848da7cd5b1d0baf38633b04","purpose":"Final matched comparison of both optimizations; distinct from earlier queue-only campaign.","responseTimeoutMs":180000,"timeoutRationale":"Declared before this final campaign because earlier unchanged baselines sometimes exceeded 120 seconds; earlier timeouts remain retained.","ui":{"mode":"built static UI","treeSha256":"7f0c2b7f586cb753408351574b2239e22311944add99af4f6f3562cd6d47bbcb"},"priorCampaign":"combined-proof stopped after a development-UI navigation timeout before Send; no failed attempts erased."},"complete":true,"statistics":{"before":{"attempted":2,"successfulMeasurements":1,"medianTtfrMs":62657.2000002861,"rangeTtfrMs":[62657.2000002861,62657.2000002861],"medianObservedReadyToRunMs":8448.5,"medianRunStartToVisibleMs":37290.199951171875},"after":{"attempted":2,"successfulMeasurements":0,"medianTtfrMs":null,"rangeTtfrMs":null,"medianObservedReadyToRunMs":0.0,"medianRunStartToVisibleMs":null}},"measuredTurnCostUsd":0.00032752,"costCoverage":"Provider-reported measured chat turns only; harness verification probes are additional and not included.","readinessMetricLimit":"First observed ready state sampled every 250 ms plus HTTP duration; clamped to zero if execution started before the observation. This is a lower bound on ready-to-start delay, not a precise sub-millisecond metric.","results":[{"phase":0,"trial":0,"handoff":"before","ttfrMs":62657.2000002861,"answer":"Hi","statuses":["succeeded"],"error":null,"runErrorCode":null,"providerHealthKilled":false,"queueMs":23467.0,"queuedBeforeObservedReady":true,"observedVerificationMsAfterFixture":26390.0,"observedReadyToRunMs":2830.0,"readyAndStartedInSameSample":false,"runStartToVisibleMs":37290.199951171875,"usage":[{"model":"openrouter/deepseek/deepseek-v4-flash-0731","costUsd":0.00032752,"costUsdExact":"0.000327520","inputTokens":26864,"outputTokens":46,"cachedInputTokens":0,"cacheWriteTokens":0,"usageSource":"per_run","costStatus":"reported"}],"workerSha256":"02adc7e1162fb60439869cec62b75d8fb22fec71155f80535c8e00211cde8326"},{"phase":1,"trial":0,"handoff":"after","ttfrMs":null,"answer":null,"statuses":["failed"],"error":"TimeoutError: page.waitForFunction: Timeout 180000ms exceeded.","runErrorCode":"provider_process_exited","providerHealthKilled":true,"queueMs":14200.0,"queuedBeforeObservedReady":true,"observedVerificationMsAfterFixture":24028.0,"observedReadyToRunMs":0,"readyAndStartedInSameSample":true,"runStartToVisibleMs":null,"usage":[{}],"workerSha256":"ceffb90a6bc97ffaaf8ec7d0267c7bfa7738d321aaf2a618b5efb9b114bc88e7"},{"phase":2,"trial":0,"handoff":"after","ttfrMs":null,"answer":null,"statuses":["failed"],"error":"TimeoutError: page.waitForFunction: Timeout 180000ms exceeded.","runErrorCode":"provider_initialize_timeout","providerHealthKilled":false,"queueMs":13877.0,"queuedBeforeObservedReady":true,"observedVerificationMsAfterFixture":18107.0,"observedReadyToRunMs":0,"readyAndStartedInSameSample":true,"runStartToVisibleMs":null,"usage":[{}],"workerSha256":"ceffb90a6bc97ffaaf8ec7d0267c7bfa7738d321aaf2a618b5efb9b114bc88e7"},{"phase":3,"trial":0,"handoff":"before","ttfrMs":null,"answer":null,"statuses":["running"],"error":"TimeoutError: page.waitForFunction: Timeout 180000ms exceeded.","runErrorCode":null,"providerHealthKilled":false,"queueMs":144256.0,"queuedBeforeObservedReady":true,"observedVerificationMsAfterFixture":136901.0,"observedReadyToRunMs":14067.0,"readyAndStartedInSameSample":false,"runStartToVisibleMs":null,"usage":[{}],"workerSha256":"02adc7e1162fb60439869cec62b75d8fb22fec71155f80535c8e00211cde8326"}],"hostLoadSample":{"at":"2026-10-09T20:48:40.031Z","parallelism":18,"load":[93.546875,82.68017578125,75.9091796875]},"measuredChangesCommit":"40b54eafb","postRebaseCommit":"c590770dcc2bb1debce14037935a7031e5c3c1ac","postRebaseLimit":"Live benchmark predates the rebase. No post-rebase live result is claimed.","variantSourceHashes":{"before":{"server/src/modules/agent-lifecycle/application/worker.ts":"02adc7e1162fb60439869cec62b75d8fb22fec71155f80535c8e00211cde8326","packages/adapters/opencode-local/src/server/test.ts":"4c10bacb55f2c7589e7b5c79d74781206ae075db8955f5b998065d6db3c0335a","server/src/adapters/registry.ts":"4a858763f69947d5745c7b8cb251a62fa404bc9683ade0ffb76282ed12761394"},"after":{"server/src/modules/agent-lifecycle/application/worker.ts":"ceffb90a6bc97ffaaf8ec7d0267c7bfa7738d321aaf2a618b5efb9b114bc88e7","packages/adapters/opencode-local/src/server/test.ts":"cd2ea3d51c3f1b4fb42261be701fb7f6b85704ad8e641e6db73d7c58e47364d3","server/src/adapters/registry.ts":"4a858763f69947d5745c7b8cb251a62fa404bc9683ade0ffb76282ed12761394"}},"model":"openrouter/deepseek/deepseek-v4-flash-0731","prompt":"Reply with Hi and an emoji and nothing else.","runnerBinary":{"profile":"release","sha256":"6134c17debda14a76da69ac9631a2d10dad482ebd58dbc9a4e4480ed652211c4"},"probeIsolation":"Explicit empty cwd and HOME/XDG folders under the synthetic fixture workspace in BOTH variants. No server checkout or ambient provider home is used for the hello request.","comparisonLabelNote":"Inner run.mts variant after selects the identical current proxy in every trial; outer campaign readinessHandoff controls before/after source changes."}
```

Queue-only campaign:

```json
{"campaign":{"order":["before","after","after","before"],"trialsPerPhase":2,"maxMeasuredTurns":8,"maxCampaignUsd":1,"sourceHead":"53580f8fbd40b963f3fb7ba5172ff3ede2e9ef1f","reasoning":"provider default, no override","proxySha256":"6691475ed55f10179ec6ee0023a1f6462982f763848da7cd5b1d0baf38633b04"},"complete":true,"statistics":{"before":{"attempted":4,"successfulMeasurements":1,"medianTtfrMs":59654.5,"rangeTtfrMs":[59654.5,59654.5],"medianObservedReadyToRunMs":19429.0,"medianRunStartToVisibleMs":8843.5},"after":{"attempted":4,"successfulMeasurements":4,"medianTtfrMs":71128.7000002861,"rangeTtfrMs":[63778.90000009537,100195.59999990463],"medianObservedReadyToRunMs":58.0,"medianRunStartToVisibleMs":26542.849975585938}},"measuredTurnCostUsd":0.0055383,"costCoverage":"Provider-reported measured chat turns only; harness verification probes are additional and not included.","readinessMetricLimit":"First observed ready state sampled every 250 ms plus HTTP duration; clamped to zero if execution started before the observation. This is a lower bound on ready-to-start delay, not a precise sub-millisecond metric.","results":[{"phase":0,"trial":0,"handoff":"before","ttfrMs":59654.5,"answer":"Hi 👋","statuses":["succeeded"],"error":null,"queueMs":50590.0,"observedReadyToRunMs":25709.0,"readyAndStartedInSameSample":false,"runStartToVisibleMs":8843.5,"usage":[{"model":"openrouter/deepseek/deepseek-v4-flash-0731","costUsd":0.00087852,"costUsdExact":"0.000878520","inputTokens":25644,"outputTokens":264,"cachedInputTokens":28416,"cacheWriteTokens":0,"usageSource":"per_run","costStatus":"reported"}],"workerSha256":"02adc7e1162fb60439869cec62b75d8fb22fec71155f80535c8e00211cde8326"},{"phase":0,"trial":1,"handoff":"before","ttfrMs":null,"answer":null,"statuses":["failed"],"error":"TimeoutError: page.waitForFunction: Timeout 120000ms exceeded.","queueMs":41651.0,"observedReadyToRunMs":14656.0,"readyAndStartedInSameSample":false,"runStartToVisibleMs":null,"usage":[{}],"workerSha256":"02adc7e1162fb60439869cec62b75d8fb22fec71155f80535c8e00211cde8326"},{"phase":1,"trial":0,"handoff":"after","ttfrMs":100195.59999990463,"answer":"Hi 🙂","statuses":["succeeded"],"error":null,"queueMs":65893.0,"observedReadyToRunMs":116.0,"readyAndStartedInSameSample":false,"runStartToVisibleMs":33243.60009765625,"usage":[{"model":"openrouter/deepseek/deepseek-v4-flash-0731","costUsd":0.00075782,"costUsdExact":"0.000757820","inputTokens":27014,"outputTokens":175,"cachedInputTokens":26368,"cacheWriteTokens":0,"usageSource":"per_run","costStatus":"reported"}],"workerSha256":"ceffb90a6bc97ffaaf8ec7d0267c7bfa7738d321aaf2a618b5efb9b114bc88e7"},{"phase":1,"trial":1,"handoff":"after","ttfrMs":65288.60000038147,"answer":"Hi 👋","statuses":["succeeded"],"error":null,"queueMs":43511.0,"observedReadyToRunMs":0,"readyAndStartedInSameSample":true,"runStartToVisibleMs":21227.60009765625,"usage":[{"model":"openrouter/deepseek/deepseek-v4-flash-0731","costUsd":0.00085326,"costUsdExact":"0.000853260","inputTokens":25166,"outputTokens":250,"cachedInputTokens":28160,"cacheWriteTokens":0,"usageSource":"per_run","costStatus":"reported"}],"workerSha256":"ceffb90a6bc97ffaaf8ec7d0267c7bfa7738d321aaf2a618b5efb9b114bc88e7"},{"phase":2,"trial":0,"handoff":"after","ttfrMs":63778.90000009537,"answer":"Hi 👋","statuses":["succeeded"],"error":null,"queueMs":39218.0,"observedReadyToRunMs":0,"readyAndStartedInSameSample":true,"runStartToVisibleMs":23479.89990234375,"usage":[{"model":"openrouter/deepseek/deepseek-v4-flash-0731","costUsd":0.000839,"costUsdExact":"0.000839000","inputTokens":27068,"outputTokens":238,"cachedInputTokens":26368,"cacheWriteTokens":0,"usageSource":"per_run","costStatus":"reported"}],"workerSha256":"ceffb90a6bc97ffaaf8ec7d0267c7bfa7738d321aaf2a618b5efb9b114bc88e7"},{"phase":2,"trial":1,"handoff":"after","ttfrMs":76968.80000019073,"answer":"Hi 👋","statuses":["succeeded"],"error":null,"queueMs":46257.0,"observedReadyToRunMs":185.0,"readyAndStartedInSameSample":false,"runStartToVisibleMs":29605.800048828125,"usage":[{"model":"openrouter/deepseek/deepseek-v4-flash-0731","costUsd":0.00030582,"costUsdExact":"0.000305820","inputTokens":24694,"outputTokens":32,"cachedInputTokens":1792,"cacheWriteTokens":0,"usageSource":"per_run","costStatus":"reported"}],"workerSha256":"ceffb90a6bc97ffaaf8ec7d0267c7bfa7738d321aaf2a618b5efb9b114bc88e7"},{"phase":3,"trial":0,"handoff":"before","ttfrMs":null,"answer":null,"statuses":["succeeded"],"error":"TimeoutError: page.waitForFunction: Timeout 120000ms exceeded.","queueMs":100979.0,"observedReadyToRunMs":3872.0,"readyAndStartedInSameSample":false,"runStartToVisibleMs":null,"usage":[{"model":"openrouter/deepseek/deepseek-v4-flash-0731","costUsd":0.00190388,"costUsdExact":"0.001903880","inputTokens":25524,"outputTokens":852,"cachedInputTokens":55808,"cacheWriteTokens":0,"usageSource":"per_run","costStatus":"reported"}],"workerSha256":"02adc7e1162fb60439869cec62b75d8fb22fec71155f80535c8e00211cde8326"},{"phase":3,"trial":1,"handoff":"before","ttfrMs":null,"answer":null,"statuses":["running"],"error":"TimeoutError: page.waitForFunction: Timeout 120000ms exceeded.","queueMs":129391.0,"observedReadyToRunMs":24202.0,"readyAndStartedInSameSample":false,"runStartToVisibleMs":null,"usage":[{}],"workerSha256":"02adc7e1162fb60439869cec62b75d8fb22fec71155f80535c8e00211cde8326"}]}
```
</details>

### Matched live browser latency evidence — 2026-10-09

**Conclusion: the overall first-response speedup is not established.**
Twelve successful real browser runs produced a lower after median, but
the updated path won only 3 of 6 adjacent pairs. Median paired
improvement was -0.17 seconds (effectively tied). Do not treat the
deterministic streaming-forwarding improvement as proof that this PR
fixes end-to-end first-response latency.

- **Actual path:** local Paperclip browser → real server/database →
current optimized Rust runner → pinned OpenCode 1.18.34 → OpenRouter
`deepseek/deepseek-v4-flash-0731`. Default reasoning in both variants;
no disabled-reasoning override.
- **Prompt:** `Reply with Hi and an emoji and nothing else.` Fresh
company, agent, chat, and per-turn native provider process for each
trial; same QA fixture instructions and selected model.
- **Comparison:** current product/runner at
`53580f8fbd40b963f3fb7ba5172ff3ede2e9ef1f` held constant. The baseline
substitutes the exact `opencode-app-server-proxy.ts`,
`opencode-proxy-task-envelope.ts`, and `opencode-server-driver.ts`
contents from pre-PR `41fec8c8921f9fb19c13fe11e2c9b71bf1017317`. This
isolates those changed modules; it is not an entire old-checkout
comparison.
- **Metric:** browser capture-phase Send click to a rendered `Hi`
markdown answer plus two animation frames, with a screenshot of the
answer. Thinking, tool labels, and status indicators do not count. This
is a DOM/render measurement, not instrumented compositor pixel timing.
- **Order/bound:** predeclared `before, after, after, before` repeated
three times; six trials per variant; 120-second response timeout; $1
campaign guard. All 12 measured trials returned `Hi` and an emoji and
finished successfully; none omitted.
- **Result:** before median **48.08 s** (37.42–66.40); after median
**42.50 s** (27.68–63.32). The 11.6% lower median alone is not
persuasive evidence of a reliable improvement given the mixed paired
results and broad overlap.
- **Remaining delay:** retained queue timestamps for 10/12 trials show
**9.76–41.65 seconds from run creation to execution start**. This is
before the OpenCode path changed here. Its cause is not established by
this benchmark. The final two queue reads were unavailable after
automatic server shutdown; all twelve primary browser timings were
retained.
- **Cost:** $0.010517369 provider-reported across the 12 measured turns;
$0.011371499 including the successful setup pilot. This is separate from
the older diagnostics listed below.
- **Retained setup attempts:** two failures before Send (composer
locator and transpiler helper); one baseline startup failure before any
observed model request (`SIGKILL` while probing OpenCode health); one
successful current-code pilot whose selector missed the visible reply.
These are retained separately, not silently included/excluded as valid
timings. The measured batch used the corrected selector and the same
release binary for both variants.
- **Limits:** local development server and headless Chromium on macOS
arm64, small sample, variable queue timing/provider routing/cache. This
does not qualify production/cloud performance or isolate the
contributions of each changed module. Server, browser, and owned runs
were stopped; original proxy build artifact restored; no product source
files changed for this experiment.

| Pair | Before: Send → visible Hi | After: Send → visible Hi | Faster
variant |
| --- | ---: | ---: | --- |
| 1 | 66.40 s | 27.68 s | After |
| 2 | 37.42 s | 28.00 s | After |
| 3 | 39.75 s | 55.22 s | Before |
| 4 | 47.32 s | 63.32 s | Before |
| 5 | 48.85 s | 29.79 s | After |
| 6 | 51.31 s | 61.07 s | Before |

<details>
<summary>Sanitized measurement records, source/binary hashes, usage, and
retained attempt classification</summary>

```json
{"manifest":{"candidate":"53580f8fbd40b963f3fb7ba5172ff3ede2e9ef1f","baseline":"41fec8c8921f9fb19c13fe11e2c9b71bf1017317","comparison":"Same current product and runner; replace the three OpenCode proxy/driver source modules with exact pre-PR contents for baseline. Reasoning uses defaults in both.","model":"openrouter/deepseek/deepseek-v4-flash-0731","prompt":"Reply with Hi and an emoji and nothing else.","samplesPerVariant":6,"order":["before","after","after","before","before","after","after","before","before","after","after","before"],"maxTurnMs":120000,"maxCostUsd":1,"variants":{"before":{"sources":{"src/cli/opencode-app-server-proxy.ts":"e9b46c937d7ce3b51dc44181a2a3ad82befda9ebe184adf80c0a6b30da140fd6","src/cli/opencode-proxy-task-envelope.ts":"fc23f8a515c2c4d1551f6b95e5b38e6254849dec23f7396e76b646e170a6e006","src/drivers/opencode/opencode-server-driver.ts":"99de415e8fc3bb5fb6f8d7512d751c4c37d2aa77a18b0546b9fb232f1a6fb6c5"},"bundleSha256":"6dd8726e72545341f6772e54ec5aeb6264dec74db67290df44930d87135efb6b"},"after":{"sources":{"src/cli/opencode-app-server-proxy.ts":"fa800e9bccce02f05f1af83e225d3b5a1300d0a7d4c35ac548f400d6c0a83f79","src/cli/opencode-proxy-task-envelope.ts":"5c387614fc9832322686326b8482eb00d88699d491b05cf59cadcd94a41194ea","src/drivers/opencode/opencode-server-driver.ts":"a944f3bf7f1bf897daec3a5dc2aa34ee9d1a2133efcb3cf150b002095a0ec586"},"bundleSha256":"6691475ed55f10179ec6ee0023a1f6462982f763848da7cd5b1d0baf38633b04"}},"runnerBinary":{"profile":"release","sha256":"6134c17debda14a76da69ac9631a2d10dad482ebd58dbc9a4e4480ed652211c4"},"metric":"Browser Send capture-phase click to visible Hi answer, sampled on animation frames, plus two paint opportunities. Excludes reasoning/tool/status text.","setupAttempts":["setup-failure-1","setup-failure-2","startup-failure-3","pilot-selector-failure"]},"summary":{"before":{"attempts":6,"measured":6,"medianMs":48083.050000190735,"minMs":37420.5,"maxMs":66397.69999980927,"totalCostUsd":0.004945229,"toolCalls":[4,1,0,0,0,1]},"after":{"attempts":6,"measured":6,"medianMs":42504.049999952316,"minMs":27680.099999904633,"maxMs":63320.90000009537,"totalCostUsd":0.00557214,"toolCalls":[0,1,0,4,0,1]},"medianReductionPercent":11.602841334350234,"medianPairedImprovementMs":-168.59999990463257,"reportedCampaignCostUsdIncludingPilot":0.011371498999999998},"pairs":[{"indices":[0,1],"beforeMs":66397.69999980927,"afterMs":27680.099999904633,"improvementMs":38717.59999990463},{"indices":[2,3],"beforeMs":37420.5,"afterMs":27997,"improvementMs":9423.5},{"indices":[4,5],"beforeMs":39751.60000038147,"afterMs":55217.199999809265,"improvementMs":-15465.599999427795},{"indices":[6,7],"beforeMs":47318.90000009537,"afterMs":63320.90000009537,"improvementMs":-16002.0},{"indices":[8,9],"beforeMs":48847.2000002861,"afterMs":29790.900000095367,"improvementMs":19056.300000190735},{"indices":[10,11],"beforeMs":51311.7000002861,"afterMs":61072.40000009537,"improvementMs":-9760.699999809265}],"trials":[{"index":0,"variant":"before","startedAt":"2026-10-09T19:15:23.172Z","measurementError":null,"ttfrMs":66397.69999980927,"domMs":66390.59999990463,"answer":"Hi 👋","runCount":1,"statuses":["succeeded"],"usage":[{"model":"openrouter/deepseek/deepseek-v4-flash-0731","costUsd":0.002612595,"costUsdExact":"0.002612595","inputTokens":55962,"outputTokens":1344,"cachedInputTokens":83456,"cacheWriteTokens":0,"usageSource":"per_run","costStatus":"reported"}],"bundleSha256":"6dd8726e72545341f6772e54ec5aeb6264dec74db67290df44930d87135efb6b","toolCalls":["paperclip_get_task_context","paperclip_comment_on_task","paperclip_finish","paperclip_finish"],"eventCounts":{"turn.started":1,"tool.execution.started":4,"item.delta":1,"turn.completed":1},"eventTimes":{"session.started":"2026-10-09T19:15:58.194Z","turn.submitted":"2026-10-09T19:15:58.311Z","turn.started":"2026-10-09T19:15:59.063Z","firstTextDelta":"2026-10-09T19:16:36.039Z","turn.completed":"2026-10-09T19:16:36.118Z"},"completedRunDurationsMs":[42915.0]},{"index":1,"variant":"after","startedAt":"2026-10-09T19:16:36.598Z","measurementError":null,"ttfrMs":27680.099999904633,"domMs":27636.599999904633,"answer":"Hi","runCount":1,"statuses":["succeeded"],"usage":[{"model":"openrouter/deepseek/deepseek-v4-flash-0731","costUsd":0.00030429,"costUsdExact":"0.000304290","inputTokens":26845,"outputTokens":28,"cachedInputTokens":0,"cacheWriteTokens":0,"usageSource":"per_run","costStatus":"reported"}],"bundleSha256":"6691475ed55f10179ec6ee0023a1f6462982f763848da7cd5b1d0baf38633b04","toolCalls":[],"eventCounts":{"turn.started":1,"item.delta":3,"turn.completed":1},"eventTimes":{"session.started":"2026-10-09T19:17:00.717Z","turn.submitted":"2026-10-09T19:17:00.841Z","turn.started":"2026-10-09T19:17:01.508Z","firstTextDelta":"2026-10-09T19:17:05.934Z","turn.completed":"2026-10-09T19:17:06.205Z"},"completedRunDurationsMs":[14435.0]},{"index":2,"variant":"after","startedAt":"2026-10-09T19:17:08.762Z","measurementError":null,"ttfrMs":27997,"domMs":27984.599999904633,"answer":"Hi 👋","runCount":1,"statuses":["succeeded"],"usage":[{"model":"openrouter/deepseek/deepseek-v4-flash-0731","costUsd":0.00101016,"costUsdExact":"0.001010160","inputTokens":27544,"outputTokens":366,"cachedInputTokens":26624,"cacheWriteTokens":0,"usageSource":"per_run","costStatus":"reported"}],"bundleSha256":"6691475ed55f10179ec6ee0023a1f6462982f763848da7cd5b1d0baf38633b04","toolCalls":["paperclip_finish"],"eventCounts":{"turn.started":1,"tool.execution.started":1,"item.delta":1,"turn.completed":1},"eventTimes":{"session.started":"2026-10-09T19:17:26.192Z","turn.submitted":"2026-10-09T19:17:26.305Z","turn.started":"2026-10-09T19:17:26.936Z","firstTextDelta":"2026-10-09T19:17:40.688Z","turn.completed":"2026-10-09T19:17:40.826Z"},"completedRunDurationsMs":[17763.0]},{"index":3,"variant":"before","startedAt":"2026-10-09T19:17:41.376Z","measurementError":null,"ttfrMs":37420.5,"domMs":37364.40000009537,"answer":"Hi 👋","runCount":1,"statuses":["succeeded"],"usage":[{"model":"openrouter/deepseek/deepseek-v4-flash-0731","costUsd":0.000693018,"costUsdExact":"0.000693018","inputTokens":24236,"outputTokens":282,"cachedInputTokens":27648,"cacheWriteTokens":0,"usageSource":"per_run","costStatus":"reported"}],"bundleSha256":"6dd8726e72545341f6772e54ec5aeb6264dec74db67290df44930d87135efb6b","toolCalls":["paperclip_finish"],"eventCounts":{"turn.started":1,"tool.execution.started":1,"item.delta":1,"turn.completed":1},"eventTimes":{"session.started":"2026-10-09T19:18:12.381Z","turn.submitted":"2026-10-09T19:18:12.616Z","turn.started":"2026-10-09T19:18:13.441Z","firstTextDelta":"2026-10-09T19:18:21.746Z","turn.completed":"2026-10-09T19:18:22.262Z"},"completedRunDurationsMs":[29216.0]},{"index":4,"variant":"before","startedAt":"2026-10-09T19:18:23.000Z","measurementError":null,"ttfrMs":39751.60000038147,"domMs":39705.7000002861,"answer":"Hi 👋","runCount":1,"statuses":["succeeded"],"usage":[{"model":"openrouter/deepseek/deepseek-v4-flash-0731","costUsd":0.000265747,"costUsdExact":"0.000265747","inputTokens":26123,"outputTokens":77,"cachedInputTokens":0,"cacheWriteTokens":0,"usageSource":"per_run","costStatus":"reported"}],"bundleSha256":"6dd8726e72545341f6772e54ec5aeb6264dec74db67290df44930d87135efb6b","toolCalls":[],"eventCounts":{"turn.started":1,"item.delta":1,"turn.completed":1},"eventTimes":{"session.started":"2026-10-09T19:19:01.000Z","turn.submitted":"2026-10-09T19:19:01.163Z","turn.started":"2026-10-09T19:19:01.893Z","firstTextDelta":"2026-10-09T19:19:07.169Z","turn.completed":"2026-10-09T19:19:07.384Z"},"completedRunDurationsMs":[15843.0]},{"index":5,"variant":"after","startedAt":"2026-10-09T19:19:10.050Z","measurementError":null,"ttfrMs":55217.199999809265,"domMs":55204.89999961853,"answer":"Hi 🙂","runCount":1,"statuses":["succeeded"],"usage":[{"model":"openrouter/deepseek/deepseek-v4-flash-0731","costUsd":0.00030421,"costUsdExact":"0.000304210","inputTokens":4294,"outputTokens":28,"cachedInputTokens":22543,"cacheWriteTokens":0,"usageSource":"per_run","costStatus":"reported"}],"bundleSha256":"6691475ed55f10179ec6ee0023a1f6462982f763848da7cd5b1d0baf38633b04","toolCalls":[],"eventCounts":{"turn.started":1,"item.delta":1,"turn.completed":1},"eventTimes":{"session.started":"2026-10-09T19:19:59.877Z","turn.submitted":"2026-10-09T19:19:59.990Z","turn.started":"2026-10-09T19:20:00.713Z","firstTextDelta":"2026-10-09T19:20:07.290Z","turn.completed":"2026-10-09T19:20:07.658Z"},"completedRunDurationsMs":[15104.0]},{"index":6,"variant":"after","startedAt":"2026-10-09T19:20:09.550Z","measurementError":null,"ttfrMs":63320.90000009537,"domMs":63313.09999990463,"answer":"Hi 👋","runCount":1,"statuses":["succeeded"],"usage":[{"model":"openrouter/deepseek/deepseek-v4-flash-0731","costUsd":0.00260723,"costUsdExact":"0.002607230","inputTokens":137971,"outputTokens":951,"cachedInputTokens":1024,"cacheWriteTokens":0,"usageSource":"per_run","costStatus":"reported"}],"bundleSha256":"6691475ed55f10179ec6ee0023a1f6462982f763848da7cd5b1d0baf38633b04","toolCalls":["paperclip_comment_on_task","paperclip_get_task_context","paperclip_comment_on_task","paperclip_finish"],"eventCounts":{"turn.started":1,"tool.execution.started":4,"item.delta":1,"turn.completed":1},"eventTimes":{"session.started":"2026-10-09T19:20:59.730Z","turn.submitted":"2026-10-09T19:20:59.859Z","turn.started":"2026-10-09T19:21:00.501Z","firstTextDelta":"2026-10-09T19:21:19.416Z","turn.completed":"2026-10-09T19:21:19.608Z"},"completedRunDurationsMs":[26519.0]},{"index":7,"variant":"before","startedAt":"2026-10-09T19:21:20.519Z","measurementError":null,"ttfrMs":47318.90000009537,"domMs":47246.800000190735,"answer":"Hi 👋","runCount":1,"statuses":["succeeded"],"usage":[{"model":"openrouter/deepseek/deepseek-v4-flash-0731","costUsd":0.000257965,"costUsdExact":"0.000257965","inputTokens":4700,"outputTokens":71,"cachedInputTokens":21407,"cacheWriteTokens":0,"usageSource":"per_run","costStatus":"reported"}],"bundleSha256":"6dd8726e72545341f6772e54ec5aeb6264dec74db67290df44930d87135efb6b","toolCalls":[],"eventCounts":{"turn.started":1,"item.delta":1,"turn.completed":1},"eventTimes":{"session.started":"2026-10-09T19:22:03.924Z","turn.submitted":"2026-10-09T19:22:04.439Z","turn.started":"2026-10-09T19:22:04.772Z","firstTextDelta":"2026-10-09T19:22:09.915Z","turn.completed":"2026-10-09T19:22:10.343Z"},"completedRunDurationsMs":[16702.0]},{"index":8,"variant":"before","startedAt":"2026-10-09T19:22:11.124Z","measurementError":null,"ttfrMs":48847.2000002861,"domMs":48802.59999990463,"answer":"Hi 🎉","runCount":1,"statuses":["succeeded"],"usage":[{"model":"openrouter/deepseek/deepseek-v4-flash-0731","costUsd":0.000327654,"costUsdExact":"0.000327654","inputTokens":44796,"outputTokens":32,"cachedInputTokens":0,"cacheWriteTokens":0,"usageSource":"per_run","costStatus":"reported"}],"bundleSha256":"6dd8726e72545341f6772e54ec5aeb6264dec74db67290df44930d87135efb6b","toolCalls":[],"eventCounts":{"turn.started":1,"item.delta":1,"turn.completed":1},"eventTimes":{"session.started":"2026-10-09T19:22:57.182Z","turn.submitted":"2026-10-09T19:22:57.281Z","turn.started":"2026-10-09T19:22:57.988Z","firstTextDelta":"2026-10-09T19:23:02.915Z","turn.completed":"2026-10-09T19:23:03.035Z"},"completedRunDurationsMs":[9541.0]},{"index":9,"variant":"after","startedAt":"2026-10-09T19:23:03.579Z","measurementError":null,"ttfrMs":29790.900000095367,"domMs":29748.800000190735,"answer":"Hi 👋","runCount":1,"statuses":["succeeded"],"usage":[{"model":"openrouter/deepseek/deepseek-v4-flash-0731","costUsd":0.00034054,"costUsdExact":"0.000340540","inputTokens":24710,"outputTokens":59,"cachedInputTokens":1792,"cacheWriteTokens":0,"usageSource":"per_run","costStatus":"reported"}],"bundleSha256":"6691475ed55f10179ec6ee0023a1f6462982f763848da7cd5b1d0baf38633b04","toolCalls":[],"eventCounts":{"turn.started":1,"item.delta":2,"turn.completed":1},"eventTimes":{"session.started":"2026-10-09T19:23:28.840Z","turn.submitted":"2026-10-09T19:23:28.954Z","turn.started":"2026-10-09T19:23:29.666Z","firstTextDelta":"2026-10-09T19:23:35.306Z","turn.completed":"2026-10-09T19:23:35.531Z"},"completedRunDurationsMs":[12732.0]},{"index":10,"variant":"after","startedAt":"2026-10-09T19:23:37.247Z","measurementError":null,"ttfrMs":61072.40000009537,"domMs":61061.300000190735,"answer":"Hi 👋","runCount":1,"statuses":["succeeded"],"usage":[{"model":"openrouter/deepseek/deepseek-v4-flash-0731","costUsd":0.00100571,"costUsdExact":"0.001005710","inputTokens":25307,"outputTokens":368,"cachedInputTokens":28160,"cacheWriteTokens":0,"usageSource":"per_run","costStatus":"reported"}],"bundleSha256":"6691475ed55f10179ec6ee0023a1f6462982f763848da7cd5b1d0baf38633b04","toolCalls":["paperclip_finish"],"eventCounts":{"turn.started":1,"tool.execution.started":1,"item.delta":1,"turn.completed":1},"eventTimes":{"session.started":"2026-10-09T19:24:33.721Z","turn.submitted":"2026-10-09T19:24:33.832Z","turn.started":"2026-10-09T19:24:34.601Z","firstTextDelta":"2026-10-09T19:24:43.135Z","turn.completed":"2026-10-09T19:24:43.278Z"},"completedRunDurationsMs":[17387.0]},{"index":11,"variant":"before","startedAt":"2026-10-09T19:24:44.710Z","measurementError":null,"ttfrMs":51311.7000002861,"domMs":51300.5,"answer":"Hi 👋","runCount":1,"statuses":["succeeded"],"usage":[{"model":"openrouter/deepseek/deepseek-v4-flash-0731","costUsd":0.00078825,"costUsdExact":"0.000788250","inputTokens":24316,"outputTokens":356,"cachedInputTokens":27648,"cacheWriteTokens":0,"usageSource":"per_run","costStatus":"reported"}],"bundleSha256":"6dd8726e72545341f6772e54ec5aeb6264dec74db67290df44930d87135efb6b","toolCalls":["paperclip_finish"],"eventCounts":{"turn.started":1,"tool.execution.started":1,"item.delta":1,"turn.completed":1},"eventTimes":{"session.started":"2026-10-09T19:25:31.347Z","turn.submitted":"2026-10-09T19:25:31.463Z","turn.started":"2026-10-09T19:25:32.208Z","firstTextDelta":"2026-10-09T19:25:39.405Z","turn.completed":"2026-10-09T19:25:39.563Z"},"completedRunDurationsMs":[15446.0]}],"additionalAttempts":[{"attempt":"setup-1","stage":"before_send","result":"non-editable composer locator","modelRequestObserved":false},{"attempt":"setup-2","stage":"before_send","result":"transpiler helper absent in browser","modelRequestObserved":false},{"attempt":"startup-pilot-before","runnerProfile":"debug","result":"no visible reply within 120s; OpenCode health startup SIGKILL; retained infrastructure failure","modelRequestObserved":false},{"attempt":"selector-pilot-after","runnerProfile":"debug","result":"Hi reply succeeded; browser selector missed it; no valid TTFR","providerReportedCostUsd":0.00085413}],"queueTimingCoverage":{"measuredTrials":10,"totalTrials":12,"records":[{"index":0,"runs":[{"createdAt":"2026-10-09T19:15:28.998Z","startedAt":"2026-10-09T19:15:53.558Z","queuedMs":24560.0}]},{"index":1,"runs":[{"createdAt":"2026-10-09T19:16:38.520Z","startedAt":"2026-10-09T19:16:54.157Z","queuedMs":15637.0}]},{"index":2,"runs":[{"createdAt":"2026-10-09T19:17:10.587Z","startedAt":"2026-10-09T19:17:23.561Z","queuedMs":12974.0}]},{"index":3,"runs":[{"createdAt":"2026-10-09T19:17:43.807Z","startedAt":"2026-10-09T19:17:53.564Z","queuedMs":9757.0}]},{"index":4,"runs":[{"createdAt":"2026-10-09T19:18:28.113Z","startedAt":"2026-10-09T19:18:54.011Z","queuedMs":25898.0}]},{"index":5,"runs":[{"createdAt":"2026-10-09T19:19:12.343Z","startedAt":"2026-10-09T19:19:53.993Z","queuedMs":41650.0}]},{"index":6,"runs":[{"createdAt":"2026-10-09T19:20:14.258Z","startedAt":"2026-10-09T19:20:53.866Z","queuedMs":39608.0}]},{"index":7,"runs":[{"createdAt":"2026-10-09T19:21:23.019Z","startedAt":"2026-10-09T19:21:54.298Z","queuedMs":31279.0}]},{"index":8,"runs":[{"createdAt":"2026-10-09T19:22:14.671Z","startedAt":"2026-10-09T19:22:53.933Z","queuedMs":39262.0}]},{"index":9,"runs":[{"createdAt":"2026-10-09T19:23:06.155Z","startedAt":"2026-10-09T19:23:23.941Z","queuedMs":17786.0}]}],"limitation":"The server shut down before a final API read; queue timing is retained for the first ten trials only."},"conclusion":"No reliable end-to-end speedup established. The after median is lower, but only three of six adjacent matched pairs were faster; median paired improvement is approximately zero. The deterministic streaming-forwarding fix remains separately supported.","limitations":["Local dev server and headless Chromium on macOS arm64; six fresh chats per variant; not a production/cloud latency guarantee.","Current product and current release runner held constant; only three OpenCode modules replaced by their exact pre-PR contents for baseline.","Default model reasoning unchanged; no no-reasoning override or decision policy.","Upstream routing/cache and queue time vary; trials run serially in predeclared counterbalanced ABBA order.","Render proxy: capture-phase Send click to visible nonempty Hi markdown, then two animation frames; screenshot verifies the rendered answer. This is not instrumented compositor pixel timing.","Two setup-only failures, one startup failure, and one invalid-measurement successful pilot are retained separately, not silently treated as successful timings."],"reproducibility":{"harnessSha256":"f93996b306c7d31c19bfcb9cc197653f867944ffb900d81a23a34f831e17c0c5","variantBuilderSha256":"4e88a7bbdc0de5d72601194ebe3b96d9c6f6eda5a33d8e0f00cf2d5d14db34c2","analyzerSha256":"2d129177a1b8033f768aad3c82dc8fff70ba0ba76e7270b2d8612ee760d4aaf4"}}
```

</details>

- Latest options-struct refactor: 33 provider tests and 86 backend tests
passed. Four port-dependent fixtures initially failed inside the sandbox
and passed with loopback access. Latest check snapshot: 51 successes, 4
skips, and 2 failures (`ci / e2e` and `ci / e2e shard (5/8)`). This
latency experiment does not make the PR merge-ready.

- Focused TypeScript suites passed: OpenCode driver, per-turn reasoning,
prepared-context recovery, proxy envelope, live sessions, Codex context
control, and native instruction measurements. The real runnerd/proxy
integration test confirms disabled followed by default in the same
session. A stale staged binary initially hid the new parameter;
rebuilding the release runner resolved it.
- Capability-guard regression suites passed all 53 tests; the real
runner/proxy boundary still accepts the per-turn selection. Both
OpenRouter and non-OpenRouter real runner/proxy cases passed, along with
62 existing OpenCode fixture tests. Runner TypeScript build passed after
the final correction.
- Affected Rust modules passed: 33 provider tests and 86 durable backend
tests. Malformed or unsupported reasoning selections do not dispatch a
provider turn or poison admission state.
- Pinned OpenCode 1.18.34 with a local OpenRouter-compatible fixture
sent three successive provider requests with reasoning omitted,
`enabled: false`, then omitted. Each request contained one copy of the
agent instructions. No paid provider call was needed.
- The reconstructed first-turn task envelope shrank from 4,356 to 460
characters. This is a payload-size measurement, not a claimed token or
latency reduction. Existing stored conversation history is not
rewritten.
- Prior head `8c07fe2e70267a7c8a3118f1071eca73ecdc6c8a`: all 53 checks
passed, with four intentional skips. Greptile is 5/5 with no open
findings. One bounded retry cleared first-test timeouts, a legacy
signoff lease failure, and CI runner shutdowns. The timed-out suites
also passed locally (14 auth-signal tests, 40 device-login tests, and
the runner resume case).
- Known reported diagnostic usage totals about $0.0029. Two prematurely
closed baseline warm-turn attempts are excluded from latency comparisons
and their usage is not fully captured; background provider usage is also
not fully accounted. No further paid runs are active.
- `pnpm -r typecheck`: passed. Runner TypeScript typechecks and build
also passed after the review correction.
- `pnpm test:run`: attempted and stopped after a reproducible failure in
the untouched Codex recovery test `archives a damaged prior epoch before
completing guarded same-task replacement with real runnerd`
(`server/src/services/native-runtime/native-session-resume.test.ts:1072`).
It returns `needs_review` / failed instead of done / succeeded,
including when rerun alone. The broad local suite is not green. The two
initial collection failures disappeared after building; their isolated
rerun passed all 1,565 tests.
- `pnpm build`: passed.
- Pinned real OpenCode plus a delayed local provider: first native delta
to Paperclip delta fell from 1,496 ms to less than 1 ms at millisecond
measurement resolution. This isolates forwarding delay, not
network/model inference.
- Two live driver samples after the patch reached first text in
2.28–2.62 seconds including session startup. Baseline first responses
took 12.37–13.43 seconds. Small unmatched samples had different
reasoning/cache outcomes, so these are observations, not an attributed
speedup ratio.
- Full compiled runnerd protocol diagnostic: completed with `Hi 👋`;
first assistant delta at 9.19 seconds and cleanup complete at 9.44
seconds. The seeded mock-control-plane harness used 27,512 input tokens
across two provider requests, split into 13,521 and 13,991. The extra
`answer_status_question` call posted `Hi 👋` to the mock task in 12 ms,
then caused another model request. A reconstruction contains 25 tools
(35.3k JSON characters), 12.8k system characters, and a 4.4k task
envelope; 3.9k instruction characters appear in both system and task
context. Provider-reported cost was $0.00073976; the pricing-table
estimate was $0.00395332. This is one integration diagnostic, not a
production latency claim.
- A separate full-runner sample with existing lazy tool discovery also
completed, but took 14.64 seconds through cleanup and called
`paperclip_finish`. Its usage ledger recorded 12,888 input and 11,776
cached-input tokens, with $0.00096344 provider-reported cost. This did
not establish a latency improvement, so the default tool exposure was
not changed.
- Four direct OpenRouter requests separately measured default reasoning
at 2.45/3.90 seconds to first text and disabled reasoning at 0.66/0.53
seconds. Default reasoning remains unchanged; disabling it requires the
explicit per-turn option.

## Risks

- Immediate readiness dispatch changes scheduling timing. It uses
existing admission checks and preserves the periodic sweep as recovery.
- Native model verification relies on the real request rather than
catalog enumeration. The final live batch had provider startup failures;
overall speed and reliability are not qualified.
- Incremental chunks without IDs cannot safely be deduplicated by
content. Repeated identical chunks are valid. Known-ID duplicates remain
suppressed.
- OpenCode uses its bundled model catalog plus the explicit
selected-model entry. New remote catalog metadata will not refresh
during isolated startup.
- Default provider plugins are disabled. This runner supplies
credentials and assigned MCP tools through its own controlled
configuration.
- Disabled reasoning is supported only on the OpenCode/OpenRouter path
and requires a compatible model. It applies to one turn and is not an
automatic latency policy.
- The matched live browser sample is small and does not establish a
reliable end-to-end improvement. It includes local queueing/startup and
browser rendering, but does not qualify production or remote sandbox
latency.

## Model Used

OpenAI GPT-6 (Codex). Tool use, code editing, and command execution were
used. The session does not expose a more specific model ID or
context-window size. Live diagnostic target: DeepSeek
`deepseek/deepseek-v4-flash-0731` through OpenRouter and OpenCode
1.18.34.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-10 07:15:41 -05:00
Nicky LeachandPaperclip 5badea4798 Use the agent lifecycle contract for plugin setup and status (#15391)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Some plugins must prepare resources before an agent can work.
> - Those resources must stop or be removed when the agent pauses or
terminates.
> - The agent lifecycle module now owns these steps and their durable
completion.
> - Plugins use its required callback contract instead of a separate
readiness query.
> - This PR documents that contract and shows its progress and retry
action on the agent page.

## Linked Issues or Issue Description

Refs: #15631

**Subsystem affected**

Required plugin lifecycle work and the agent detail page.

**Problem or motivation**

The lifecycle module exposes `onAgentLifecycle` and
`agents.lifecycle.manage`, but the plugin authoring guide does not
explain their use. The agent page displays legacy status. Setup
therefore looks paused and offers Resume. Termination leaves the page
before resource cleanup completes.

**Proposed solution**

Use the callback, admission guards, persisted state, and retry route
supplied by the lifecycle module. Show pending phases and fixed host
errors. Keep the detail page open during incomplete termination. Use
lifecycle state for pause and resume controls.

**Alternatives considered**

A separate plugin readiness query would duplicate the module's state and
admission rules. The final diff contains no readiness API, database
commit hooks, or second admission check.

## What Changed

- Document the manifest declaration, callback results, phase ordering,
capability scope, retry behavior, and provider revision requirements.
- Correct SDK comments for lifecycle, routing, and external object
callbacks.
- Show preparation, verification, pause, resume, termination, and
cleanup progress from the existing agent fields.
- Refresh pending steps every two seconds and other states every thirty
seconds. Keep these reads separate from the agent record used by
editable forms. Use the existing authorized retry route for failed
steps.
- Offer Pause during setup. Disable pause and resume while stop or
cleanup is incomplete. Keep cleanup failures reachable on the detail
page. Disable new work after termination starts.
- Test required resource work across setup, pause, resume, and confirmed
deletion. Test every worker callback phase, missing handlers, retry
authorization, and UI behavior.

## Verification

- Focused UI tests: 139 passed across lifecycle status, action buttons,
API tests, and the configuration form. A regression preserves unsaved
edits during an in-flight lifecycle refresh and a failed refresh.
- Focused host and shared tests: 113 passed across lifecycle
integration, retry authorization, plugin RPC, and manifest validation.
The database suite used isolated local PostgreSQL 18 through a temporary
loader outside the repository.
- Module boundary tests: 11 passed. Module import/write checks and UI
token gates passed.
- Plugin SDK build, server direct TypeScript check, and UI TypeScript
check passed.
- `git diff --check` and an added-line secrets/private-reference scan
passed. Fixtures use synthetic identities.
- Full `pnpm -r typecheck` and `pnpm build` passed using the existing
local Rust toolchain.
- Visually checked all six progress phases with the application
components and styles. Captured preparation and cleanup failure
screenshots from the production UI build with synthetic API data;
artifacts are kept outside git.
- Full local `pnpm test:run` was attempted and stopped after a
native-runner failure reproduced in isolation:
`native-session-resume.test.ts` / `archives a damaged prior epoch before
completing guarded same-task replacement with real runnerd` returns a
failed Codex fixture result instead of success. The native-runtime and
runner trees are unchanged from the base. No full local test pass is
claimed.
- All latest-head CI checks passed, including test shards, verification,
E2E, and the canary packaging check. Greptile reviewed commit
`96473e03a` at 5/5 with no unresolved comments.

## Risks

- Selected required plugins remain a dependency for lifecycle
completion. The lifecycle module retains their requirements when a
plugin is disabled or removed.
- Plugins must make repeated provider operations safe and fence older
revisions. Host revision checks do not cancel external requests.
- Polling adds one agent-detail read every two seconds during pending
work and every thirty seconds otherwise.
- No schema change, private VM provider implementation, resource
backfill, or deployment is included. No live provider test was run.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, repository inspection, code
editing, and command execution. The exact model revision and context
window were not reported by the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either linked existing issues or described the issue in-PR
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change and contains no internal
ticket id
- [x] I have run focused tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented risks
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no unresolved comments
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-09 11:15:24 -07:00
Nicky LeachandPaperclip 2dd9811e8b Own agent hiring and termination in a lifecycle module (#15631)
## Thinking Path

> - Paperclip manages AI agents and their work.
> - Agent creation, approval, pause, and termination change whether an
agent can work.
> - These changes currently use separate service and database paths.
> - Some agents need setup after their record exists and before their
harness can run.
> - This PR adds one module that owns those state changes and their
transactions.
> - The module records progress and retries incomplete work after a
server restart.

## Linked Issues or Issue Description

**Problem or motivation**

A hire can become active before its execution environment is ready.
Direct agent state writes also bypass one common lifecycle boundary.
Termination does not have a durable step for resource cleanup.

**Proposed solution**

Use agent lifecycle commands for hiring, approval, pause, resume, and
termination. Keep proposed hires pending until approval. Prepare each
approved hire, test its saved harness configuration, and then permit
work. Wait for required plugin work before completing a step.

**Alternatives considered**

A general database commit hook would expose transaction details to
callers. A timer alone would delay each new hire. This module owns each
command transaction and starts its worker after commit. A periodic scan
recovers work after a process stops.

**Roadmap alignment**

This change adds the lifecycle boundary for the existing agent and
plugin systems. It does not add a user interface or a provider
implementation. It replaces the lifecycle delivery approach in #15391.
Related proposals include #11475 and #13099; this change does not add
their external executor protocols.

## What Changed

- Add durable approval, preparation, verification, pause, resume, stop,
and cleanup states.
- Keep agent creation, lifecycle state, configuration writes, and agent
record deletion behind `modules/agent-lifecycle`. Keep validation,
credential rules, reads, onboarding, invitations, general approvals, and
company data deletion in their services.
- Commit a hire before the remaining onboarding or invitation work.
Serialize requests with the existing dedicated database connection
helper. Reuse the hire after a failed attempt.
- Keep accepted work in the run queue during setup. Require execution
and cleanup evidence before completing pause or termination.
- Retain the original credential owner when approving migrated hires.
- Start lifecycle work after each command commits. Use database leases
and revision checks for retries and late results.
- Add a generic required plugin method and capability. Store the
required plugin IDs for each agent. Track host and plugin completion
separately.
- Keep harness verification and credential rules in services. Supply
their results to lifecycle through explicit ports. Reject failed logins
and incomplete probes.
- Remove reverse imports from lifecycle adapters into services. Share
record queries and validation below both layers.
- Use one hire decision path for agent IDs and approval IDs. Resolve the
approval and agent change in one transaction. Expose policy
reconciliation on the command object.
- Save configuration and verification invalidation through one module
operation. Ordinary commands own their transaction. Restrict the shared
transaction integration to its listed callers. Reject identity, company,
accounting, timestamp, and unknown fields.
- Require completed termination before deleting agent records. The
company deletion service calls the module through a separate deletion
interface.
- Save an approved secret before applying its agent binding. A failed
binding leaves the secret approved. A retry reuses it.
- Remove old permission and metadata export files. Move hire
configuration rules out of lifecycle persistence.
- Report failed worker steps and scans with operation context and known
error codes. Omit provider messages, stacks, and configuration values.
Document ordinary application use and extension rules.
- Recover expired native owners only after execution stops. Retry
cleanup of failed retained leases. Wait for setup in the live smoke
script.
- Keep manual, budget, and company pause reasons separate. Guard
execution status writes with the ready state.
- Add a migration, boundary checks, regression tests, and a lifecycle
guide. Keep the current UI.
- Limit periodic policy checks to 100 eligible agents per minute.
Continue from the last agent ID. Exclude pending hires and agents in
termination or a final state. Keep explicit company and budget updates
immediate.

## Verification

- Latest head `ccc3c86b0` aligns the shared status update schema with
the PATCH route. All 15 shared tests, 104 server route/OpenAPI tests, 2
CLI tests, and shared/server/CLI TypeScript checks pass. The Greptile
comment is fixed. CI is running; no new Apex review was requested for
this small fix.

- Rebase `75708ac94` rebases onto master at `2eb5187e4`. The backup test
uses the shared 90-second timeout from master; the older timeout commit
was removed. The affected backup test passes.
- Agent budget edits now pass the target agent ID through enforcement.
All 50 affected tests, server TypeScript, and 11 boundary tests pass.
Both configuration update paths have regression coverage. CI is running
on the latest head. No new Apex review was requested for these small
fixes.

- Rebase `9b568666a` preserves the run-control extraction and
delivery-work startup changes from master. All 559 affected tests,
server TypeScript, and 11 boundary tests pass. CI and Apex review are
running on the final head.
- The policy scan fix passes 56 lifecycle and diagnostic tests, 24
policy and caller tests, and 11 boundary tests. A regression test checks
the page limit, interval, excluded states, and cursor wraparound.

- Rebase `fd4698750` passes all 611 tests in eight affected lifecycle,
route, and heartbeat suites. Server TypeScript and all 11 module
boundary tests pass. The company deletion rollback and concurrent hire
tests are included.

- Follow-up `00aac7646` fixes the failed decision deletion test and the
public update schema. All 126 affected server tests, 8 shared tests, 2
CLI tests, and 11 boundary tests pass. Shared, server, CLI, and UI
TypeScript checks pass.

- Server TypeScript passes: `pnpm --filter @paperclipai/server exec tsc
--noEmit`.
- All 11 boundary tests pass: `node --test
scripts/check-module-boundaries.test.mjs`. The repository boundary scan
also passes.
- All 68 lifecycle, failure diagnostic, and harness verification tests
pass. Tests reject forbidden fields through the root command, combined
transition command, and restricted transaction integration. They also
check invalid fields returned by configuration preparation.
- Failure tests cover host work, plugin work, claims, renewals,
deferrals, and scans. Log tests check known error codes and exclusion of
private error contents.
- All 355 tests in 10 existing caller suites pass. These tests cover
secret approval, pairing, pending hires, credentials, company import,
built-in agents, managed agents, instructions, and skills.
- The branch is rebased onto `origin/master` at `2eb5187e4`. The queue
guards and cancellation lock fix use the extracted heartbeat queue.
Agent cancellation uses the extracted run-control file. The source diff
scan found no secrets or private URLs.
- Earlier checks cover approval retries, rollback, late verification
results, policy holds, deletion rollback, concurrent company deletion,
and queue admission during setup.
- Before the rebase, CI passed on `00aac7646` and Greptile Apex rated it
5/5 with no open comments. CI and a new Apex review are pending for the
rebased commit. The branch has no merge conflicts.
- The full local build and native Dot suite were not run. The host has
no Cargo. No live provider test was run.

## Risks

- Background policy recovery takes more than one minute when more than
100 agents are eligible. Explicit company and budget updates still
reconcile immediately.

- The restricted configuration integration keeps an agent reference and
its dependent records in one transaction. It cannot change a budget or
invoke a lifecycle transition. A periodic scan starts pending setup
after that transaction commits.
- Secret approval and binding use separate transactions. A failed
binding can leave an approved secret with no agent binding.
- New approved hires remain unavailable until setup and verification
complete. Existing clients see `paused` during these steps. This PR does
not change the UI.
- Migration `0320_daily_onslaught.sql` maps existing statuses and pause
reasons. It does not create external resources or claim that an existing
harness passed verification.
- Use a coordinated server update for this migration. Older server
versions can write legacy status without the new lifecycle checks.
- A failed onboarding or invitation request can leave a committed hire.
A retry reuses that hire and completes the remaining records.
- Deletion requires completed termination. Company deletion can require
another request while cleanup is pending.
- Required plugins must make repeated calls safe and reject older
revisions. A disabled or removed required plugin blocks completion until
it is restored.
- A new hire can run a second harness test after the existing UI test.
Some adapters can charge for this test.
- No live provider test was run. Provider implementation and resource
backfill remain separate work.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, code tools, and local test
execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have described the issue in this PR with the feature request
field labels
- [x] I have not referenced internal or instance-local issues or links
- [x] My branch name describes the change
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation
- [x] I have considered and documented risks
- [x] Targeted local tests pass
- [x] All Paperclip CI gates are green on the new commit
- [x] Greptile Apex is 5/5 with no open comments on the new commit
- [x] I will address review comments before requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-09 09:05:12 -07:00