fix(native): wake parents after revised child completions (#15372)

## Thinking Path

> - Paperclip manages agents and the tasks they perform.
> - A parent needs a new notification when a child finishes requested
revisions.
> - Native completion currently identifies that wake by the parent and
child only.
> - An already consumed notification can suppress the child's next
completion.
> - This PR binds ordinary child notifications to the committed status
decision.
> - A replay keeps the same identity, while a new completion can wake
the parent.
> - Live evaluation then exposed new child results being swallowed by an
active parent.
> - The repair preserves that input for a fresh turn and prevents
premature Done from discarding it.

## Linked Issues or Issue Description

Refs: #15218. The procedure experiments remain unshipped. This fixes
native completion wake identity and durable delivery to a busy parent.

Related open work: #10559 and #4507 address legacy wake deduplication,
#11179 addresses delivery during an active parent run, and #13044
addresses watchdog signals. This PR changes native committed-decision
keys and their delivery to an active parent, while preserving exact
watchdog behavior.

**What happened?**

A native child completed, its parent consumed the notification, and
feedback reopened the child. The second completion found the old
completed wake and created no new notification.

**Expected behavior**

Each new ordinary child completion can notify the parent. Replaying the
same committed completion must not add a notification. If the parent is
already running, the new result must remain available for a fresh turn;
an unread result must survive a parent Done claim.

**Steps to reproduce**

Complete a native child, consume its parent wake, reopen and complete
that same child, then inspect the parent wakes. The regression fails on
unchanged master because only one wake exists after two completions.

**Paperclip version or commit**

Baseline: `0fe47882cfcb12082035113c59ca96091c46ebfc`.

**Deployment mode**

Native runner with the standard server and PostgreSQL control plane.

## What Changed

- Include the durable status-decision ID in ordinary child completion
wake keys.
- Defer new ordinary native child completions behind an active parent,
carrying the committed decision identity and revised summary.
- Keep a parent in progress while that result is still queued, claimed
or deferred; recheck before the status commit. Use the existing
continuation without reviving cancelled tasks.
- Lock the parent before ordinary child status writes, making the
notification/Done ordering explicit without upgrading an implicit
foreign-key lock.
- Cover exact sequential delivery, dispatcher replay, and
queued/claimed/deferred versus consumed/current-run completion
identities in database tests.
- Apply it when the child is also a dependency and when it is only a
child.
- Preserve the stable key for exact `task_watchdog` origins to avoid
repeated watchdog loops.
- Extend real database conformance coverage for both relationships,
ordinary and near-match origins, watchdogs, revised summaries, and
replay.
- Reconcile the working checklist with the merged guidance PRs and
record the bounded next step.
- Reuse the current composer helper for Everyday task creation: capture
the returned task ID, preserve the exact prompt and chosen
assignee/project, and cover the setup with paused-agent browser tests.
The same setup correction is present in both comparison variants.

## Verification

**Ready for review and merge at
`e2fc0c8e3ddb84dd9bc045704c3d1ecb23ee3447`: both original live cases
pass, zero new failures against the frozen baseline, all checks green,
CLEAN/MERGEABLE and out of draft. Not merged.**

| Profile | Original baseline | Initial candidate | Fixed candidate |
| --- | --- | --- | --- |
| native Codex / `gpt-5.6-sol` | PASS | FAIL | PASS |
| ACPX Claude / `claude-sonnet-5` | FAIL | PASS | PASS |

One new pass, one unchanged pass, zero new failures and no pending pairs
against the original baseline. The earlier failed candidate is
preserved; it was fixed and measured at a new source, not regraded or
rerolled unchanged.

### Current source and live evidence

- [Completed campaign
37523025407](https://github.com/paperclipai/paperclip/actions/runs/37523025407)
and [public report with original
screenshots](https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-37523025407-1/index.html).
Report and screenshots return HTTP 200; screenshot bytes match original
retained evidence.
- Measured candidate `e2fc0c8e3ddb84dd9bc045704c3d1ecb23ee3447`; reused
frozen baseline `7d700f43e93e89e4c196f8799b0aa3cef41d20da`. Trusted
workflow `88ff98b83d15eaa6640ee1848df4f0ad9bc82af3` is distinct from
measured sources; workflow blob
`0600886144d3e22ea2e4a38329a79177882f3948`. Prompts, fixture, oracle,
model/profile and input controls are unchanged. Both variants include
the same composer setup repair.
- Both original candidate grades PASS: all 31 checks per cell, parent
and child Done, independently tested revised ZIPs, successful cleanup.
Exactly one first attempt per profile on this source: 10 actual runs, no
retries. Reusing nine baseline runs gives 19 matched runs; the baseline
was not rerun.
- Claude directly exercised the repair: its revised child completed
while the parent was active, the parent's Done claim became InProgress
with `native_child_completion_pending`, and a fresh parent turn then
completed with the revised artifact.
- Both persisted final provider comments refer to the same attachment
whose parent-registration hash matches the revised ZIP tested by the
original oracle; native final evidence also references that attachment.
Codex provides a clickable download link. Claude describes the new
`--max-length` behavior but uses a backticked attachment ID, without a
clickable URL in the final prose. The artifact is registered on the
parent and independently downloaded/tested; prose-link usability remains
a presentation limit outside the original oracle. Artifact identity and
observed execution do not prove cognitive review.
- 68 focused scheduling/conformance/arbiter tests pass. Four regressions
fail against the original production files while four controls pass. The
database scheduler test proves one sequential continuation with the
revised summary and replay deduplication. It uses a mock adapter and is
separate from the live proof.
- Full local `pnpm -r typecheck` and `pnpm build` pass. [Exact-head full
CI
37522126627](https://github.com/paperclipai/paperclip/actions/runs/37522126627):
51 successful checks, two intentional skips, separate Snyk success.
Fresh exact-head Greptile
[5/5](https://github.com/paperclipai/paperclip/pull/15372#issuecomment-6022179172),
no new actionable findings; all review threads resolved.
- Ready-transition Contributor trust and Superagent Security Scan both
pass. The security scan completed at 2026-10-06T20:26:53Z with zero
annotations. Final total: 53 successful check runs, two intentional
skips and separate Snyk success; aggregate SUCCESS, source unchanged,
zero unresolved threads.
- The explicit parent lock precedes child writes. Its controlled
PostgreSQL ordering also passes on previous production through an
implicit foreign-key lock; this is hardening, not a reproduced
additional live failure. The four original failing regressions remain
the before/after proof of the active-parent repair.

### Preserved failures and accounting

- Initial candidate `a2ae2324ce7692e704fc43e99904b076d6246fee`: Codex
PASS → FAIL, Claude FAIL → PASS. Equal totals concealed a new failure
and did not qualify that source. Its Codex parent ended Blocked after
the revised child result coalesced into the active parent; there was no
retained later parent execution and the independent ZIP oracle was never
reached. A later server backstop log did not prove recovery.
- In the initial Claude cells, both parent finals referenced an earlier
parent ZIP while the oracle tested the revised child's ZIP. The original
candidate PASS did not establish latest-artifact delivery. Earlier
parent ZIP bytes are absent, so different hashes alone do not prove
missing functionality. Those original grades and content findings are
unchanged.
- Original baseline
[report](https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-37509893406-1/index.html);
original candidate Claude
[artifact](https://github.com/paperclipai/paperclip/actions/runs/37509916344/artifacts/11434353106);
failed candidate Codex
[campaign](https://github.com/paperclipai/paperclip/actions/runs/37513070706).
The original cohort contains 17 actual runs, no retries, successful
cleanup.
- Campaign 37509916344 was cancelled after its Codex job waited over 16
minutes without a runner or steps. Completed Claude evidence was
preserved; only the missing Codex first trial was dispatched in
37513070706. The trusted workflow revision differs but workflow bytes
are identical. The cancelled campaign has no published HTML. Earlier
campaigns 37506546098 / 37506550087 retain four composer setup failures,
zero agent runs; the identical fixture-only repair restored setup
without changing the prompt or outcome oracle.
- Intermediate `3c1cf6830ce9db4123b834ffb99c594c383a8986` [campaign
37518652522](https://github.com/paperclipai/paperclip/actions/runs/37518652522)
was cancelled after a concurrency review finding. Both paid-cell steps
started and both tasks were created. Only invocation policies survived;
no grade, run inventory or cleanup receipt. Provider activity and
charges are unknown: two incomplete attempts, not passes or
zero-provider setup failures.
- Cumulative accounting: **27 known actual runs** (17 original + 10
fixed-candidate), plus unknown activity in those two cancelled
intermediate attempts. The 19-run matched comparison reuses nine
baseline runs and is not additional execution. Reported LLM amounts are
zero with original billing `complete=true`; actual charges are unknown
and local/hosted runtime is unmetered. No free-run, speed or cost claim.
- Original and new results pass the canonical result validator. Retained
source, input, result/API/story/final-ledger/usage run identities and
artifact hashes were audited. Each downloaded package omits the
pre-upload-declared `playwright-output/.last-run.json`; primary result,
API, final ledger, story, screenshots and reached ZIP oracles are
retained. No full-package completeness claim.
- The initial redundant local full test invocation was stopped after
2,492.5 seconds once that head's CI passed; completed groups recorded
23,045 passes and 87 skips. That local invocation remains incomplete.
Current full CI is the repository-wide test evidence.

## Risks

A revised completion can schedule another sequential parent run and its
normal budget use. A parent Done claim remains non-terminal while a
newer native child result awaits delivery. Company scope, governance,
workspace-finalization, terminal cancellation and exact watchdog
behavior remain enforced. This changes native completion authority and
scheduling, so the durable identity, replay and concurrent-commit
controls matter.

The two live trials qualify the observed revised-child handoff, not
broad task quality or causal/general equivalence. Claude's final prose
still gives an attachment ID without a clickable URL, although the
registered revised artifact passes the original download oracle.
Completion before any new child result exists, removal of unfinished
dependencies and broader instruction reduction remain separate
questions. There is no schema or prompt change.

## Model Used

OpenAI Codex, GPT-6 family, with repository inspection, shell execution
and code editing. The exact serving model ID, reasoning setting 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>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-06 16:11:43 -05:00
1 parent ee3c728b95
commit fe9d865250
13 files changed
+806 -121

No files matched your search

@@ -1,17 +1,25 @@
# Stock harness, with Paperclip: working checklist
Created: 2026-10-02. Status: item 1 merged for native Codex app-server in
[PR #14920](https://github.com/paperclipai/paperclip/pull/14920). Item 2's default
hire manual is reduced to identity only, and common legacy startup/resume
instructions are in [PR #14948](https://github.com/paperclipai/paperclip/pull/14948).
GitHub live qualification retains earlier document-delivery failures; the latest
focused OpenCode comparison passes both cases in both variants but still exposes
deficient original-case handoff links. PR #14948 remains draft. The original
[live report](2026-10-02-stock-harness-live-comparison.md), completed
[skill repair](2026-10-02-legacy-document-skill-repair.md) and latest
[stock-selection/link comparison](2026-10-02-opencode-skill-routing-link-qualification.md)
retain their separate sources and limitations. Additional carriers and fixed
native instructions remain open; hiring templates are being reduced separately.
Created: 2026-10-02. Status refreshed locally: 2026-10-06.
Native Codex base preservation (#14920), the tiny default manual/common legacy
prompts (#14948), general hiring templates (#14985), native completion tool
guidance (#14961), evaluator accounting repairs (#15007), and native completion
constraint/final-answer corrections (#15151) are merged. Dotta confirmed the
latest merge; GitHub records #15151 merged on 2026-10-05. The detailed sections
below retain historical stages and failures; this status supersedes their old
draft/pending descriptions. The saved master checklist had lagged behind the
implementation and evidence notes and is reconciled here. Native-tool measurement
(#15218) and the smaller planning skills (#15296) are also merged; the
2026-10-06 entry below records their scope and the next repair.
Remaining work: deferred legacy carriers (2.2), uncommon native fixed-prompt
procedures beyond the completed completion slice (2.3), specialized agent
contracts (3.4), repository context (4), broader runtime bookkeeping (5), and
the full harness/configuration audit (6). Completed evals qualify their bounded
cases, not general coding quality or every configuration. The prepared final
#15151 PR body/eval summary was not published before the human merged it;
publication remains separate from implementation completion.
Goal: keep the agent's stock harness behavior and add only what it needs to work
with Paperclip. Apply this across legacy adapters, the new Runner, and their
@@ -26,7 +34,7 @@ For every change, record its executable test/eval coverage before continuing.
Distinguish coverage setup, deterministic results, and measured live results;
configured cells alone do not qualify behavior.
**Current item: 2 — reduce the default operating manual and shared prompt layers.**
**Current item: repair and qualify the native delegation handoff before further procedure reduction.**
## Agreed direction and boundaries
@@ -106,8 +114,10 @@ This replaces 602 words with eight. The shared loader supplies this default
across instruction-bundle-capable adapters for non-CEO hires without explicit
instructions. Existing saved bundles retain their content; CEO, first-agent,
and role/team templates remain separate work under item 3. The common
prompt/wake reduction is complete locally below; additional carriers, native
instructions, and skill/reference corrections remain open.
prompt/wake reduction and legacy delivery repairs are merged in #14948;
additional carriers and uncommon native procedures remain open. Native
completion documentation and constraint/final-answer corrections are merged
separately in #14961 and #15151.
Verification: the existing agent-skills route suite passed all 54 tests,
including default creation, custom bundles, and CEO/first-agent paths. The
@@ -134,6 +144,123 @@ and existing-test update.
native tool-documentation slice on 2026-10-02 in a separate worktree. Native
`paperclip_finish`/`paperclip_block` guidance must not leak into legacy
completion paths, which use the operational skill and API.
- [x] **2.3 completion slice.** Improve native finish/block documentation
(#14961), consolidate repeated completion constraints, and preserve useful
final replies, blocker explanations and working document links (#15151).
Native Codex, ACPX Claude and OpenCode have bounded live outcome coverage;
scripted start/resume/continuation and compatibility checks remain distinct
from live resume qualification.
- [ ] **2.3 remaining procedures.** Review the fixed hiring, dependencies and
connection guidance; improve discoverable tool documentation and measure any
further reduction. #15151 intentionally did not change the fixed prompt.
Hiring/dependency relocation failed both corrected and readiness comparisons.
#15218 now retains measurement and eval coverage with production unchanged;
see [the comparison report](2026-10-05-native-procedure-guidance.md).
Connection procedures remain a separate follow-up.
- [x] **2.3 full-catalog measurement.** PR #15218 measures all 39 supplied
native tools and schemas with fixed instructions at start/resume/continuation.
The rejected corrected candidate reduced the normalized standing projection by 460 bytes
(48,781 to 48,321 in the paid context; 49,200 to 48,740 after master integration),
not a token/cost or upstream truncation claim. The rejected readiness repair
restored explicit delegation/review guidance and saved only 125 bytes.
Its larger unqualified controller repair increased the full projection.
Final #15218 restores production to baseline: 49,200 bytes, identical across
all 36 normalized components of nine scripted deliveries and the MCP catalog.
- [ ] **2.3 hiring/dependency behavior qualification.** The original six-pair
campaign exposed three new overall failures, including Claude omitting
dependency recording and finalization. Preserve original grades and control
bugs. Corrected v7 candidate and a baseline with matching credential/budget
repairs completed: baseline 5/6 PASS, candidate 3/6 PASS; two new failures,
zero new passes, three unchanged passes and one unchanged failure. Codex's
parent remained blocked after the replacement deliverable. OpenCode hiring
exceeded the deadline before the revised delivery settled. Both OpenCode
delegation variants lacked parent review of the final child revision. Two
interrupted candidate cells had one bounded recovery each; incomplete
original accounting is retained. No rerolls of these completed source pairs.
After the human requested readiness fixes, candidate
`aba7ec219b588de02cf323b4ebca559a105e6d01` restores assigned-worker revisions
and latest-child review, returns actual dependency readiness, and separates
cancelled dependencies from tasks that can complete. Its 23 database-backed
tool tests and fresh 5/5 review pass. New candidate campaign `37348723829`
and integrated baseline `37348764875` completed with unchanged oracles and
bounds: candidate 3 PASS / 3 FAIL; baseline 3 PASS / 2 FAIL / one setup cell
without a behavioral grade. The five comparable pairs have two new failures,
two new passes and one unchanged pass; one pair remains uncomparable. The
new failures include parent completion before the latest child revision and
OpenCode credential persistence. The Codex candidate also lacks parent
continuation. Keep this item open. The final #15218 excludes the production
change, retains every failure, and adds measurement and eval coverage only.
Unqualified runtime fixes are preserved locally for separate follow-up.
### Remaining fixed-prompt audit — 2026-10-05
Read-only inspection verified the prompt, semantic catalog, shared connection
guidance, production tool authority and payload-measurement file against their
exact blobs on merged master `a386a599983519eb1d399f8b770bfccdb2a74762`.
The fixed prompt is 262 whitespace-separated words / 1,713 UTF-8 bytes: 34
general context, 123 hiring/delegation/dependencies, 98 connections and seven
finishing words. These are source-text counts, not provider token/billing data.
The hiring recipe still mandates `search_api`/`call_api`, although the available
`hire_agent` tool already creates persistent native teammates and documents
runtime inheritance/reuse. Hiring and API fallback tools share the API-tool
availability gate. Update the discovery/selection guidance to prefer an
available dedicated tool and keep configuration-specific fallback in its own
documentation; provider helper threads must remain distinct from company hires.
`create_task` documents creation but omits the assigned-worker/review boundary.
`set_dependencies` describes replacement only; preserving existing blocker IDs
and releasing the workspace are still carried by the fixed prompt. Improve
these tool contracts before removing the corresponding prompt procedure.
Connection tools already document discovery, recorded provider choices, setup
cards and yielding. Keep a small discovery trigger for explicit setup requests;
put state-specific retry/decline/continuation instructions in tool descriptions
and actual result guidance. These are reduction proposals, not implemented or
behaviorally qualified changes.
Measure the complete delivered prompt, constraints and granted tool descriptions
together. The existing scripted capture selects only core semantic tools, so it
omits these optional hiring/dependency tools and the connection tools. Moving
words into descriptions alone does not demonstrate lower total instruction load.
Keep prompt revision/digest and incompatible-session checks explicit.
Candidate coverage includes the existing Product E2E `hire-reuse`,
`delegate-feedback`, service approval/decline and connection-routing stories.
Those stories cover Codex/Claude; corresponding OpenCode qualification and exact
blocker-preservation/workspace-yield assertions still need review. Some decline
fixtures explicitly restate the no-retry rule, so they cannot alone measure its
discovery from shipped guidance. Previous completion-only passes do not qualify
these procedures. Prefer hiring as the first bounded slice, then matched
before/after outcomes with common fixture/model/auth/permission/budget controls.
### Follow-up 2.3 merged completion slice — 2026-10-05
#15151 merged as `a386a599983519eb1d399f8b770bfccdb2a74762` from final source
`16ef1a5744b23bb043a8b286e6288921da1de98f`. The shortened completion contract
now retains an explicit blocker cause/owner/action and a canonical clickable
document handoff. Runtime feedback, OpenCode acceptance/settlement and proxy
ordering corrections preserve the final provider response rather than treating
an accepted terminal tool call as sufficient delivery.
The six scoped corrective cases pass: four Codex/Claude cases measured at
`3a7349ddc60142283c394121882288f2db07b215`, and two OpenCode cases measured at
the final source. The intervening production delta is confined to OpenCode;
the shared instructions, server feedback and UI paths are byte-identical.
These are explicitly mixed-source observations, not six live runs on the final
head. Document cases open the actual saved revision and original marker from
the final reply; blocker cases retain the missing-access reason, exact owner,
unblock action and task-wide scope. Final-head CI is green and fresh review is
5/5 with all review threads resolved.
Original paired grades and every corrective failure remain preserved. Passing
totals had concealed weak blocker explanations and missing document links;
the subsequent oracle explicitly checks those behaviors. Single trials do not
prove broad equivalence, cognitive skill consumption, general coding quality,
live resume behavior, or speed/cost trends. See the
[constraint plan](2026-10-03-native-completion-consolidation.md) and
[final-answer correction history](2026-10-04-native-completion-answer-fix.md)
for the preserved setup and behavior failures; their initial hold statements
are historical and superseded by this merged status.
### Follow-up 2.1 implementation and verification
@@ -328,6 +455,10 @@ isolation is approved and should remain.
## 5. Let the runtime own bookkeeping
- [x] **Native completion delivery slice.** Persist and present meaningful final
answers, saved document links and blocker details; settle accepted/rejected
OpenCode completion feedback correctly. Merged in #15151. This does not finish
the wider checkout, waiting, recovery and legacy ownership audit below.
- [ ] Map which runtime owns checkout, status transitions, completion comments,
artifacts, waiting/review states, and recovery; identify manual duplicates.
- [ ] Keep one valid completion path per runtime and preserve meaningful user
@@ -369,6 +500,10 @@ does not apply.
## Verification and evals — apply to each item
- [x] Repair strict retained-run accounting in #15007 and add bounded native
completion/final-answer coverage for #14961/#15151. Keep original failures;
ACPX host/provider action attribution and hiring source-read coverage remain
explicitly uncomparable where no authoritative mapping/receipt exists.
- [x] Map existing coverage for all implemented changes before adding cases;
see the SH-1–SH-3 map below and [doc/evals.md](../evals.md).
Keep Runner protocol evals and Product E2E evals distinct.
@@ -462,6 +597,7 @@ will address them. Record intentional behavior explicitly rather than as a bug.
| Date | Decision / outcome | Evidence / follow-up |
| --- | --- | --- |
| 2026-10-05 | Dotta merged #15151; reconcile the saved roadmap with completed implementation/evidence notes. | Native completion slice complete; general hiring reductions and common legacy prompts also merged. 2.2, remaining fixed native procedures, 3.4 and the broader 4–6 audits stay open. Preserve old failures and bounded qualification limits; no new model run or public report publication in this checklist update. |
| 2026-10-02 | Dotta approved preserving stock instructions, smaller defaults/templates, minimal coordination, runtime bookkeeping, and coverage across harnesses. | Implementation details to work through one item at a time. |
| 2026-10-02 | Paperclip-owned MCP isolation and configuration changes/session resets are acceptable. | Preserve Paperclip auth and assigned skills while fixing repository context. |
| 2026-10-02 | Dotta requested implementation and a PR for item 1. Native app-server paths now use additive developer instructions. | 139 targeted TypeScript tests and 91 Rust provider tests passed; repository typecheck/build passed. Remaining test/review results to record. |
@@ -489,3 +625,48 @@ will address them. Record intentional behavior explicitly rather than as a bug.
For each completed item, add the chosen behavior, changed paths, verification
results, remaining exceptions, and follow-ups here before checking it off.
### 2026-10-05 final PR #15218 scope
- [x] Preserve all three experimental cohorts and failed original grades.
- [x] Reject the unqualified procedure relocation; final production bytes match master a65ca0950834a85bb93bcc4b4042ecacdebfef53.
- [x] Keep complete native-tool measurement, explicit workflow coverage, selected-provider credentials and budget guards.
- [x] Full local typecheck/build and 1,252 eval-support + 128 Node tests pass.
- [x] Final head 2e7cef78eef7cdfe02265e0dcb03e855b8e50bd8: all 47 CI jobs pass; one initial annotation-test timeout retained and one targeted retry passed. Fresh Greptile 5/5, three threads resolved, published body verified, both ready-transition security scans pass, MERGEABLE and no longer draft.
- [x] PR #15218 merged on 2026-10-06 at 01:03 UTC. The preceding approval hold is historical; the final merged scope is measurement and eval coverage with production unchanged.
- [ ] Section 2.3 procedure relocation remains unshipped; the trials do not justify it. Runtime follow-up preserved separately and unqualified.
### 2026-10-06 roadmap update
- [x] **#15218 merged:** complete native-tool measurement and explicit hiring/delegation eval coverage. The proposed production procedure relocation failed qualification and was excluded. Original failures remain recorded.
- [x] **#15296 merged:** shrink the runtime and bundled plan-to-tasks skills while retaining their installation keys and accepted-plan wiring. GitHub records the merge at 14:36 UTC. The detailed completed comparison report remains local pending its separate publication consent; merging the implementation does not publish that report.
- [ ] **Next bounded repair:** a reopened native child must notify its parent after its new completion, even when the first notification has already been consumed. Replaying the same committed completion must not create another wake. Work is on `codex/delegation-handoff`, based on master `0fe47882cfcb12082035113c59ca96091c46ebfc`.
- [x] **#15372 implemented and deterministically verified:** ordinary native child completion wakes include the durable decision ID; exact watchdog behavior and replay deduplication are preserved. Both ordinary regressions fail on baseline, and all 20 database-backed cases pass with the repair. Current head `a2ae2324ce7692e704fc43e99904b076d6246fee` has full green CI and fresh 5/5 review; it remains draft because the completed live comparison contains a new failure.
- [ ] **#15372 live qualification failed; keep draft:** Codex PASS → FAIL, Claude FAIL → PASS. One new failure, one new pass, no pending pairs; equal totals do not prove non-regression. Four completed cells contain 17 actual agent runs, no retries, cleanup passed. Candidate Codex's revised child completion wakes coalesced into an active parent, which later ended Blocked using an outdated child-running view; no later parent execution is retained. A later backstop log reports repair but does not prove eventual completion. That cell never reached the independent ZIP oracle. The unstarted candidate Codex queue job was cancelled with no runner/steps before its first actual trial in campaign `37513070706`. Four earlier composer setup failures had zero agent runs and remain preserved. Original grades are unchanged; no causal attribution or reroll.
- [ ] **Latest child delivery/review remains open:** both Claude parent finals point to an earlier parent ZIP while the original oracle checks the revised child's ZIP. The candidate's passing grade does not qualify delivery/review of that latest artifact. Parent ZIP bytes were not retained, so a different hash alone is not proof of missing functionality. This is separate from the consumed-wake regression. Preserve original grades.
- [ ] Revisit hiring/dependency instruction reduction only after the bounded behavior is qualified. Premature parent completion, discarded unfinished dependencies and broader latest-child review remain separate open behavior questions.
- [ ] Audit connection procedures separately, then return to the remaining 2.2 / 3.4 / 4–6 work.
#### #15372 reporting disposition — 2026-10-06
The complete original comparison and diagnosis are saved privately at
[immutable evidence archive](https://github.com/paperclipai/paperclip-evals/blob/a3fdf327907816c22c4097da8839db4f472d5e9c/experiments/2026-10-native-delegation-handoff/README.md).
The prepared public body is `/private/tmp/delegation-handoff-pr-body.md`.
The human explicitly authorized publication and requested fixing the failures.
The prepared summary was published normally to PR #15372; the earlier automatic
review hold is resolved for this summary. The original comparison remains unchanged.
#### #15372 active-parent repair — 2026-10-06
- [x] Implement durable delivery of ordinary native child completions to a fresh parent turn when the parent is already running; preserve exact watchdog behavior.
- [x] Keep a parent non-terminal while a newer native child result remains queued or deferred, with a commit-time recheck. Preserve terminal cancellation and governance authority; do not create a second continuation.
- [x] Provider-free verification: 67 focused scheduler/native-conformance/authority tests and repository typecheck pass. The scheduler regression holds the parent open, dispatches/replays the child result, then verifies exactly one sequential continuation with the latest summary. Pending, consumed and current-run delivery identities have native finalization coverage.
- [ ] Freeze and measure the corrected source in the same two original live cases, one attempt each. Do not reroll the original baseline or regrade the failed candidate.
- [ ] Verify the latest source head in CI/review and inspect final parent artifact delivery before claiming readiness. Keep draft until these gates pass.
- [x] Preserve intermediate campaign `37518652522` as two cancelled, ungraded attempts on `3c1cf68`; provider activity/charges and cleanup are unverified because only invocation policies survived. These are separate from the original 17 actual runs.
- [x] Audit the concurrency review finding with a real database barrier and distinct agents. Existing implicit locking serializes this tested ordering; make the parent lock explicit before child status writes. Do not claim a newly reproduced live defect.
- [ ] Finish fresh source checks/review, then qualify the corrected source against the frozen baseline and inspect latest-artifact delivery.
- [x] Explicit-lock revision: 68 focused tests and full repository typecheck pass. Fresh build, CI/review and live checks remain pending.
@@ -0,0 +1,124 @@
# Native delegation handoff repair
Date: 2026-10-06. Owner: this Codex task. Branch: `codex/delegation-handoff`.
Starting master: `0fe47882cfcb12082035113c59ca96091c46ebfc`.
## Finish line
A reopened ordinary native child can complete again and deliver one fresh completion
notification to its parent. Replaying the same committed completion remains
idempotent. Prepare a reviewed, verified PR; merging is a separate human action.
## Scope
The native status committer currently deduplicates parent notifications by the
parent/child pair. A consumed notification can therefore suppress a later
completion of the same child. Scope the key to the durable completion decision
in both parent-notification branches, including a child that is also a blocker.
Keep existing readiness, company, ownership, workspace finalization and governance
gates. Do not change model prompts, tools, dependency replacement semantics or
the outcome oracle. General dependency-only wake reconciliation is separate. Exact `task_watchdog`
children keep their existing stable wake key to avoid repeated watchdog loops;
near-match origin names remain ordinary children. Related open #10559 and
#4507 concern legacy wake deduplication; #11179 concerns delivery during an
active parent run; #13044 separately proposes suppressing watchdog signals.
The initial slice changed only the native committed-decision wake key. The
failed live trial below requires the bounded native delivery extension described here.
Prior procedure experiments in #15218 exposed stalled and premature parent
outcomes but do not alone prove this mechanism caused every failure. The
production relocation and earlier controller repairs were not shipped. #15296
shipped the smaller planning skills; its report publication is separate.
## Verification contract
1. Reproduce the consumed-wake collision against unchanged master production
with the real database/control-plane conformance test. Cover parents with
and without an explicit dependency edge, the latest child summary, and
duplicate finalization.
2. Run relevant conformance checks, repository typecheck, tests and build.
Preserve failures and distinguish environment limitations.
3. Freeze candidate and baseline branches on this master with identical
Product E2E fixtures, graders, provider profiles and budgets. Run only
`everyday-workflows.runner-codex.local.delegate-feedback` and
`everyday-workflows.runner-acpx-claude.local.delegate-feedback` in each
variant: one attempt each, 12-minute per-cell deadline, at most 12 story
run records, 1,000-cent company and lead-agent hard stops, concurrency 2.
No reroll of a completed behavior failure. All provider continuations and
automatic recovery count in the actual run inventory.
4. Inspect original grades and retained chronology, including revised child
delivery, worker ownership, parent review and parent completion ordering.
A green workflow is not itself a behavioral pass. A single pair does not
establish general equivalence, causation or cost/speed trends.
5. Retain dated measurement/results in the private `paperclip-evals` archive
and existing campaign artifact storage. Keep a compact index here. Verify
current-head CI and review before a readiness claim.
## Current state
- [x] Record merged #15218 and #15296 in the working checklist.
- [x] Reproduce both relationship cases: second completion leaves only the
original wake against unchanged production.
- [x] Verify the scoped correction and replay behavior: all 20 database-backed
control-plane conformance tests pass, including exact watchdog and near-match
origin controls. Repository checks are still running.
- [ ] Complete frozen live comparison and retain original evidence.
- [ ] Complete PR review and CI.
## Active-parent failure and bounded correction
The original four live cells are complete: Codex PASS → FAIL and Claude FAIL →
PASS, 17 actual runs with no retries. Original grades remain unchanged. The
Codex candidate receives the revised child completion while its parent is active;
the parent later blocks using an outdated child-running view, with no retained
continuation. Both Claude parent finals reference an earlier artifact, so their
original grades do not prove delivery/review of the latest child artifact.
The human authorized publishing this summary and fixing the failures. The PR body
now retains the original results and limits. The next source revision:
- Carries ordinary child completion decision identity through the durable wake.
- Defers that wake behind an active parent instead of treating updated run context
as proof the provider received it. Exact watchdog and legacy behavior stay as before.
- Preserves a queued/deferred child result across a parent Done claim. Completion
authority leaves the task in progress and uses the existing continuation; it
rechecks under the parent status lock. Consumed and current-run wakes do not
block completion. Cancellation and governance are not bypassed.
67 focused scheduler, native conformance and authority tests pass, along with
repository typecheck. The controlled scheduler test proves one sequential parent
continuation carrying the revised summary, including dispatcher replay. Native
coverage distinguishes queued, claimed, deferred, consumed, and current-run wakes.
The original baseline remains frozen. Measure only the corrected candidate in
the same two cases, one attempt each, under the existing bounds and oracle. Keep
all previous runs and inspect final artifact delivery separately from machine
pass totals. Live qualification and fresh CI/review remain pending; keep draft.
### Concurrency review and cancelled intermediate campaign
Fresh review of `3c1cf6830ce9db4123b834ffb99c594c383a8986` raised an
outbox-insertion versus parent-completion race. A controlled PostgreSQL barrier
uses distinct parent and child agents and pauses the real child transaction at
wake insertion. The parent must wait, then preserve the pending result. The
previous source also passes that ordering because child writes acquire an
implicit parent foreign-key lock. This is not a reproduced additional failure.
The correction makes parent serialization explicit before child writes, avoiding
shared-lock upgrades between siblings. The original four failing regressions
remain the before/after evidence for the active-parent fix.
Intermediate campaign `37518652522` was cancelled once that review required
source changes. Both paid-cell steps started and both tasks were created before
cancellation completed. Each retained artifact contains only its invocation
policy. No original grade, API run inventory, final ledger or cleanup receipt
survived. Provider-run count and charges are unknown; record two incomplete
attempts, not passes or zero-provider setup failures. Preserve these separately
from the original 17 actual runs. The next candidate must pass fresh review
before its bounded live attempts. Original baseline, prompts and oracle stay
frozen.
The final lock revision passes all 68 focused scheduler, conformance and arbiter
tests plus repository typecheck. Build, fresh CI/review and live qualification
remain pending.
@@ -170,6 +170,88 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
await tempDb?.cleanup();
});
it("delivers a revised native child completion after the running parent blocks", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const childId = randomUUID();
const decisionId = randomUUID();
let finishParent!: () => void;
const parentFinished = new Promise<void>((resolve) => { finishParent = resolve; });
mockAdapterExecute.mockImplementationOnce(async () => {
await parentFinished;
await db.update(issues).set({ status: "blocked" }).where(eq(issues.id, issueId));
return { exitCode: 0, signal: null, timedOut: false, errorMessage: null,
summary: "Waiting for the child revision.", provider: "test", model: "test-model" };
});
mockAdapterExecute.mockImplementationOnce(async () => {
const [run] = await db.select().from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.status, "running"),
));
await db.insert(issueComments).values({ companyId, issueId, authorAgentId: agentId,
authorType: "agent", createdByRunId: run!.id, body: "Reviewed and delivered the revised ZIP." });
await db.update(issues).set({ status: "done" }).where(eq(issues.id, issueId));
return { exitCode: 0, signal: null, timedOut: false, errorMessage: null,
summary: "Reviewed and delivered the revised ZIP.", provider: "test", model: "test-model" };
});
await db.insert(companies).values({ id: companyId, name: "Native handoff",
issuePrefix: `N${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false, defaultResponsibleUserId: "responsible-user" });
await db.insert(agents).values({ id: agentId, companyId, name: "Parent", role: "engineer",
status: "active", adapterType: "codex_local", adapterConfig: {}, permissions: {},
runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } } });
await db.insert(issues).values([
{ id: issueId, companyId, title: "Deliver the revised ZIP", status: "todo",
assigneeAgentId: agentId, responsibleUserId: "responsible-user" },
{ id: childId, companyId, parentId: issueId, title: "Revise the ZIP", status: "done",
responsibleUserId: "responsible-user" },
]);
try {
const parentRun = await heartbeat.wakeup(agentId, { source: "assignment", triggerDetail: "system",
reason: "issue_assigned", payload: { issueId }, contextSnapshot: { issueId, wakeReason: "issue_assigned" } });
expect(parentRun).not.toBeNull();
await db.insert(issueComments).values({ companyId, issueId, authorAgentId: agentId,
authorType: "agent", createdByRunId: parentRun!.id, body: "Waiting for the child revision." });
expect(await waitForCondition(async () => mockAdapterExecute.mock.calls.length === 1, 30_000)).toBe(true);
const [intent] = await db.insert(agentWakeupRequests).values({ companyId, agentId,
source: "automation", triggerDetail: "system", reason: "issue_children_completed",
requestedByActorType: "system", requestedByActorId: "native-status-committer",
idempotencyKey: `issue_children_completed:${issueId}:${childId}:${decisionId}`,
payload: { issueId, taskId: issueId, _paperclipWakeContext: {
issueId, taskId: issueId, source: "native_status_decision",
nativeChildCompletionDecisionId: decisionId, completedChildIssueId: childId,
childIssueIds: [childId], childIssueSummaries: [{ id: childId, summary: "Revised ZIP: 11 tests." }],
} },
}).returning();
await heartbeat.dispatchPendingNativeStatusWakeups({ companyId });
await heartbeat.dispatchPendingNativeStatusWakeups({ companyId });
const deferred = await db.select().from(agentWakeupRequests).where(and(
eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.status, "deferred_issue_execution"),
));
expect(deferred).toHaveLength(1);
expect(deferred[0]!.runId).toBeNull();
expect(mockAdapterExecute).toHaveBeenCalledTimes(1);
finishParent();
expect(await waitForCondition(async () => mockAdapterExecute.mock.calls.length === 2, 10_000)).toBe(true);
await heartbeat.drainActiveRunExecutions();
const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId));
expect(runs.map((run) => ({ id: run.id, status: run.status, context: run.contextSnapshot, error: run.error }))).toHaveLength(2);
const continuation = runs.find((run) => run.id !== parentRun!.id)!;
expect(continuation.contextSnapshot).toMatchObject({
wakeReason: "issue_children_completed", nativeChildCompletionDecisionId: decisionId,
nativeStatusWakeIntentId: intent!.id, completedChildIssueId: childId,
childIssueSummaries: [{ id: childId, summary: "Revised ZIP: 11 tests." }],
});
expect(continuation.status).toBe("succeeded");
const first = runs.find((run) => run.id === parentRun!.id)!;
expect(continuation.startedAt!.getTime()).toBeGreaterThanOrEqual(first.finishedAt!.getTime());
await heartbeat.dispatchPendingNativeStatusWakeups({ companyId });
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId))).toHaveLength(2);
} finally {
finishParent();
}
}, 45_000);
it("dispatches and coalesces durable native status wake intents into one heartbeat run", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
+5 -1
View File
@@ -7112,7 +7112,11 @@ export function shouldQueueFollowupForRunningIssueWake(input: {
return true;
}
const wakeReason = readNonEmptyString(input.contextSnapshot?.wakeReason);
if (wakeReason === "issue_children_completed" && input.contextSnapshot?.onboardingCompletion === true) return true;
if (wakeReason === "issue_children_completed" && (
input.contextSnapshot?.onboardingCompletion === true ||
(input.contextSnapshot?.statusDecisionSource === "native_status_decision" &&
readNonEmptyString(input.contextSnapshot?.nativeChildCompletionDecisionId))
)) return true;
return Boolean(
wakeReason && RUNNING_ISSUE_WAKE_REASONS_REQUIRING_FOLLOWUP.has(wakeReason),
);
@@ -0,0 +1,30 @@
import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
import { agentWakeupRequests, type Db } from "@paperclipai/db";
export interface NativeChildCompletionRecipient {
companyId: string;
issueId: string;
agentId: string;
runId: string;
sourceIntentId: string | null;
}
/** New child results belong to a fresh parent turn, including across a Done claim. */
export async function hasPendingNativeChildCompletion(db: Db, input: NativeChildCompletionRecipient) {
const [pending] = await db.select({ id: agentWakeupRequests.id }).from(agentWakeupRequests).where(and(
eq(agentWakeupRequests.companyId, input.companyId),
eq(agentWakeupRequests.agentId, input.agentId),
or(eq(agentWakeupRequests.reason, "issue_children_completed"),
and(eq(agentWakeupRequests.reason, "issue_execution_deferred"),
sql`${agentWakeupRequests.payload}->'_paperclipWakeContext'->>'wakeReason' = 'issue_children_completed'`)),
eq(agentWakeupRequests.requestedByActorType, "system"),
or(eq(agentWakeupRequests.requestedByActorId, "native-status-committer"),
sql`${agentWakeupRequests.requestedByActorId} like 'native-status-wake-dispatch:%'`),
inArray(agentWakeupRequests.status, ["queued", "claimed", "deferred_issue_execution"]),
sql`${agentWakeupRequests.payload}->>'issueId' = ${input.issueId}`,
sql`nullif(${agentWakeupRequests.payload}->'_paperclipWakeContext'->>'nativeChildCompletionDecisionId', '') is not null`,
or(isNull(agentWakeupRequests.runId), ne(agentWakeupRequests.runId, input.runId)),
input.sourceIntentId ? ne(agentWakeupRequests.id, input.sourceIntentId) : undefined,
)).limit(1);
return Boolean(pending);
}
@@ -1,3 +1,4 @@
import { hasPendingNativeChildCompletion } from "./native-child-completion-delivery.js";
import { settleSlackConversation } from "../slack-conversation-lifecycle.js";
import { executionFailureRetryCount } from "../execution-recovery-attempt.js";
import { readPersistedNativeProviderFailure } from "./native-provider-failure-evidence.js";
@@ -1284,7 +1285,15 @@ export async function finalizeNativeRun(input: {
(!authoritativeIssue.executionRunId || authoritativeIssue.executionRunId === run.id) &&
(reviewContext ? nativeReview?.interaction.status === "pending"
: authoritativeIssue.assigneeAgentId === run.agentId && !authoritativeIssue.assigneeUserId);
const childCompletionRecipient = {
companyId: run.companyId, issueId: authoritativeIssue.id, agentId: run.agentId, runId: run.id,
sourceIntentId: typeof record(run.contextSnapshot).nativeStatusWakeIntentId === "string"
? record(run.contextSnapshot).nativeStatusWakeIntentId as string : null,
};
const hasPendingChildCompletion = !reviewContext &&
await hasPendingNativeChildCompletion(input.db, childCompletionRecipient);
const proposedDecision = resolveNativeFinalizerStatus({
hasPendingChildCompletion,
providerModelRejected: providerFailure?.errorCode === "native_provider_model_rejected" && ownsProviderFailureDecision,
providerOverloaded: providerFailure?.errorCode === "native_provider_overloaded" && ownsProviderFailureDecision,
providerFailureSuperseded: providerFailure?.errorCode === "native_provider_overloaded" && !ownsProviderFailureDecision,
@@ -1371,6 +1380,8 @@ export async function finalizeNativeRun(input: {
priorStatusVersion: Number(authoritativeIssue.statusVersion),
priorDecisionId: authoritativeIssue.lastStatusDecisionId,
decision,
requireNoPendingChildCompletion: decision.statusAction === "done" && !reviewContext
? childCompletionRecipient : undefined,
requireModelRejectionOwner: decision.reasonCode === "native_provider_model_rejected"
? { agentId: run.agentId, reviewContext }
: undefined,
@@ -1495,12 +1495,13 @@ describe("PaperclipControlPlanePort conformance", () => {
});
it("completes DOT-29-style low-risk work with an environment caveat and no corrective run", async () => {
it.each(["none", "queued", "claimed", "deferred_issue_execution", "coalesced", "current_run", "current_intent"])(
"preserves completion caveats and pending child delivery (%s)", async (delivery) => {
const identity = CONTROL_PLANE_CONFORMANCE_OPEN.identity;
const issueId = "30000000-0000-4000-8000-000000000029";
const localContractId = "31000000-0000-4000-8000-000000000029";
const runId = "32000000-0000-4000-8000-000000000029";
const runnerInstanceId = "33000000-0000-4000-8000-000000000029";
const issueId = randomUUID();
const localContractId = randomUUID();
const runId = randomUUID();
const runnerInstanceId = randomUUID();
await db.insert(issues).values({
id: issueId,
companyId: identity.companyId,
@@ -1578,10 +1579,28 @@ describe("PaperclipControlPlanePort conformance", () => {
terminal: CONTROL_PLANE_CONFORMANCE_TERMINAL,
callerResultId: "dot-29-result",
});
const pendingDelivery = ["queued", "claimed", "deferred_issue_execution"].includes(delivery);
if (delivery !== "none") {
const intentId = randomUUID();
await db.insert(agentWakeupRequests).values({
id: intentId, companyId: identity.companyId, agentId: identity.agentId,
source: "automation", triggerDetail: "system", reason: delivery === "deferred_issue_execution" ? "issue_execution_deferred" : "issue_children_completed",
requestedByActorType: "system", requestedByActorId: delivery === "deferred_issue_execution"
? `native-status-wake-dispatch:${randomUUID()}` : "native-status-committer",
status: delivery.startsWith("current_") ? "claimed" : delivery,
runId: delivery === "current_run" ? runId : null,
payload: { issueId, _paperclipWakeContext: { wakeReason: "issue_children_completed", nativeChildCompletionDecisionId: randomUUID() } },
});
if (delivery === "current_intent") await db.update(heartbeatRuns)
.set({ contextSnapshot: { issueId, nativeStatusWakeIntentId: intentId } })
.where(eq(heartbeatRuns.id, runId));
}
const expectedStatus = pendingDelivery ? "in_progress" : "done";
const expectedReason = pendingDelivery ? "native_child_completion_pending" : "completion_claim_policy_accepted";
await finalizeNativeRun({ db, runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: true });
await expect(db.select().from(issues).where(eq(issues.id, issueId))).resolves.toEqual([
expect.objectContaining({ status: "done", statusVersion: 1 }),
expect.objectContaining({ status: expectedStatus, statusVersion: 1 }),
]);
await expect(db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issueId))).resolves.toEqual([]);
await expect(db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, identity.companyId)))
@@ -1589,12 +1608,12 @@ describe("PaperclipControlPlanePort conformance", () => {
expect.objectContaining({ payload: expect.objectContaining({ issueId, sourceRunId: runId }) }),
]));
await expect(db.select().from(statusDecisions).where(eq(statusDecisions.issueId, issueId))).resolves.toEqual([
expect.objectContaining({ toStatus: "done", reasonCode: "completion_claim_policy_accepted" }),
expect.objectContaining({ toStatus: expectedStatus, reasonCode: expectedReason }),
]);
const [persistedRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
expect(persistedRun).toMatchObject({ status: "succeeded", nativePhase: "committed" });
expect(persistedRun?.resultJson).toMatchObject({
finalizationReasonCode: "completion_claim_policy_accepted",
finalizationReasonCode: expectedReason,
verificationCaveats: [{
commandOrCheck: "Run npm test",
reasonCode: "tool_unavailable",
@@ -1607,13 +1626,116 @@ describe("PaperclipControlPlanePort conformance", () => {
});
});
it("coalesces a completed child dependency into one rich parent wake", async () => {
it("serializes a concurrent child notification with the parent's completion check", async () => {
const identity = CONTROL_PLANE_CONFORMANCE_OPEN.identity;
const parentIssueId = "30000000-0000-4000-8000-000000000145";
const childIssueId = "30000000-0000-4000-8000-000000000146";
const localContractId = "31000000-0000-4000-8000-000000000146";
const runId = "32000000-0000-4000-8000-000000000146";
const runnerInstanceId = "33000000-0000-4000-8000-000000000146";
async function preparedRun(parentId: string | null) {
const agentId = randomUUID();
await db.insert(agents).values({ id: agentId, companyId: identity.companyId,
name: parentId ? "Child worker" : "Parent lead", adapterType: "codex_local", status: "running" });
const issueId = randomUUID();
const runId = randomUUID();
const localContractId = randomUUID();
const runnerInstanceId = randomUUID();
await db.insert(issues).values({ id: issueId, companyId: identity.companyId, parentId,
title: parentId ? "Revised child" : "Deliver revised child result", status: "in_progress",
assigneeAgentId: agentId, workMode: "standard" });
await db.insert(completionContracts).values({ id: localContractId, companyId: identity.companyId,
issueId, revision: 1, schemaVersion: "paperclip.completion-contract.v1", policyVersion: "phase6-v3",
risk: "low", completionAuthority: "agent_claim_policy", incompleteCriteriaPolicy: "preserve_non_terminal",
contractJson: { revision: "handoff-race-v1", objective: "Deliver the requested result",
criteria: [{ id: "objective", requirement: "Complete the requested work" }] },
canonicalSha256: localContractId, createdByActorType: "system", createdByActorId: "test" });
await db.insert(heartbeatRuns).values({ id: runId, companyId: identity.companyId, agentId,
status: "succeeded", runtimeMode: "native", nativeIssueId: issueId, nativeSessionId: identity.sessionId,
runnerInstanceId, completionContractId: localContractId, completionContractSha256: localContractId,
contextSnapshot: { issueId } });
const port = new PaperclipControlPlanePort(db, { ...identity, agentId, issueId, runId,
completionContractId: localContractId, completionContractSha256: localContractId,
sourceInstanceId: runnerInstanceId, controlPlaneSourceInstanceId: "handoff-race" });
await port.openRun({ identity: { ...identity, agentId, issueId, runId }, backendKind: "mock", sourceInstanceId: runnerInstanceId });
await port.completeRun({ result: { ...structuredClone(CONTROL_PLANE_CONFORMANCE_RESULT),
summary: parentId ? "Revised result is ready." : "The result is delivered.",
completionClaim: { contractRevision: "handoff-race-v1", objectiveSatisfied: true,
criteria: [{ criterionId: "objective", status: "satisfied", evidenceRefs: [] }], remainingWork: [] },
evidence: [], verification: [{ commandOrCheck: "node --test", status: "passed" }], attentionRequests: [],
}, terminal: CONTROL_PLANE_CONFORMANCE_TERMINAL, callerResultId: "handoff-race-result" });
return { issueId, runId };
}
const parent = await preparedRun(null);
const child = await preparedRun(parent.issueId);
const suffix = randomUUID().replace(/-/g, "");
const trigger = `handoff_barrier_${suffix}`;
const lockKey = Number.parseInt(suffix.slice(0, 7), 16);
// A test-only database barrier pauses the real child transaction after it
// reads the parent and immediately before it inserts the completion wake.
await db.execute(sql.raw(`create function ${trigger}() returns trigger language plpgsql as $$
begin
if NEW.requested_by_actor_id = 'native-status-committer'
and NEW.payload->>'issueId' = '${parent.issueId}' then
perform pg_advisory_xact_lock(${lockKey}, 1);
end if;
return NEW;
end $$`));
await db.execute(sql.raw(`create trigger ${trigger} before insert on agent_wakeup_requests
for each row execute function ${trigger}()`));
let childCompletion: Promise<unknown> | undefined;
let parentCompletion: Promise<unknown> | undefined;
let parentSettled = false;
async function waitUntil(check: () => Promise<boolean>) {
for (let attempt = 0; attempt < 500; attempt++) {
if (await check()) return true;
await new Promise(resolve => setTimeout(resolve, 10));
}
return false;
}
try {
await db.transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(${lockKey}::integer, 1)`);
const [controller] = await tx.execute(sql`select pg_backend_pid() as pid`) as unknown as Array<{ pid: number }>;
childCompletion = finalizeNativeRun({ db, runId: child.runId, workspaceFinalizeStatus: "succeeded" })
.then(() => null, (error: unknown) => error);
let childPid: number | null = null;
expect(await waitUntil(async () => {
const [blocked] = await db.execute(sql`select pid from pg_stat_activity
where ${controller.pid} = any(pg_blocking_pids(pid)) limit 1`) as unknown as Array<{ pid: number }>;
childPid = blocked?.pid ?? null;
return childPid !== null;
})).toBe(true);
parentCompletion = finalizeNativeRun({ db, runId: parent.runId, workspaceFinalizeStatus: "succeeded" })
.then(() => { parentSettled = true; return null; }, (error: unknown) => { parentSettled = true; return error; });
let parentWaiting = false;
expect(await waitUntil(async () => {
const [state] = await db.execute(sql`select exists (select 1 from pg_stat_activity
where ${childPid}::integer = any(pg_blocking_pids(pid))) as waiting`) as unknown as Array<{ waiting: boolean }>;
parentWaiting = state.waiting;
return parentWaiting || parentSettled;
})).toBe(true);
expect(parentSettled).toBe(false);
expect(parentWaiting).toBe(true);
});
expect(await childCompletion).toBeNull();
expect(await parentCompletion).toBeNull();
expect((await db.select().from(issues).where(eq(issues.id, child.issueId)))[0]!.status).toBe("done");
expect((await db.select().from(issues).where(eq(issues.id, parent.issueId)))[0]!.status).toBe("in_progress");
expect(await db.select().from(statusDecisions).where(eq(statusDecisions.issueId, parent.issueId)))
.toEqual([expect.objectContaining({ reasonCode: "native_child_completion_pending" })]);
const wakes = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, identity.companyId));
expect(wakes.filter(wake => wake.payload?.issueId === parent.issueId)).toHaveLength(1);
} finally {
await Promise.all([childCompletion, parentCompletion]);
await db.execute(sql.raw(`drop trigger ${trigger} on agent_wakeup_requests`));
await db.execute(sql.raw(`drop function ${trigger}()`));
}
}, 30_000);
it.each(["child_dependency", "child"].flatMap((relationship) =>
["manual", "task_watchdog", "task_watchdog_product_bug"].map((originKind) => ({ relationship, originKind })),
))("preserves completion wake identity across reopen ($relationship, $originKind)", async ({ relationship, originKind }) => {
const hasDependency = relationship !== "child";
const identity = CONTROL_PLANE_CONFORMANCE_OPEN.identity;
const parentIssueId = randomUUID();
const childIssueId = randomUUID();
const localContractId = randomUUID();
await db.insert(issues).values([
{
id: parentIssueId,
@@ -1628,12 +1750,13 @@ describe("PaperclipControlPlanePort conformance", () => {
companyId: identity.companyId,
parentId: parentIssueId,
title: "Implement the accepted plan",
originKind,
status: "in_progress",
assigneeAgentId: identity.agentId,
workMode: "standard",
},
]);
await db.insert(issueRelations).values({
if (hasDependency) await db.insert(issueRelations).values({
companyId: identity.companyId,
issueId: childIssueId,
relatedIssueId: parentIssueId,
@@ -1658,80 +1781,111 @@ describe("PaperclipControlPlanePort conformance", () => {
createdByActorType: "system",
createdByActorId: "test",
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId: identity.companyId,
agentId: identity.agentId,
status: "running",
runtimeMode: "native",
nativeIssueId: childIssueId,
nativeSessionId: identity.sessionId,
runnerInstanceId,
completionContractId: localContractId,
completionContractSha256: "child-wake-contract",
contextSnapshot: { issueId: childIssueId },
});
const port = new PaperclipControlPlanePort(db, {
companyId: identity.companyId,
issueId: childIssueId,
runId,
agentId: identity.agentId,
sessionId: identity.sessionId,
completionContractId: localContractId,
completionContractSha256: "child-wake-contract",
sourceInstanceId: runnerInstanceId,
controlPlaneSourceInstanceId: "child-wake-control",
});
await port.openRun({
identity: { ...identity, issueId: childIssueId, runId },
backendKind: "mock",
sourceInstanceId: runnerInstanceId,
});
const result: PrpStructuredRunResult = {
...structuredClone(CONTROL_PLANE_CONFORMANCE_RESULT),
summary: "Implemented the accepted plan and passed 44/44 tests.",
completionClaim: {
contractRevision: "child-wake-v1",
objectiveSatisfied: true,
criteria: [{ criterionId: "objective", status: "satisfied", evidenceRefs: [] }],
remainingWork: [],
},
verification: [{ commandOrCheck: "node --test", status: "passed" }],
attentionRequests: [],
};
await port.completeRun({
result,
terminal: CONTROL_PLANE_CONFORMANCE_TERMINAL,
callerResultId: "child-wake-result",
});
await finalizeNativeRun({ db, runId, workspaceFinalizeStatus: "succeeded" });
for (const revision of [1, 2]) {
const runId = randomUUID();
const runnerInstanceId = randomUUID();
if (revision === 2) {
// The original completion wake was already consumed. Feedback reopens
// the same child while its parent still needs the revised result.
await db.update(agentWakeupRequests).set({ status: "completed" })
.where(sql`${agentWakeupRequests.payload}->>'issueId' = ${parentIssueId}`);
await db.update(issues).set({ status: "blocked" }).where(eq(issues.id, parentIssueId));
await db.update(issues).set({ status: "in_progress", statusVersion: 2 })
.where(eq(issues.id, childIssueId));
}
await db.insert(heartbeatRuns).values({
id: runId,
companyId: identity.companyId,
agentId: identity.agentId,
status: "running",
runtimeMode: "native",
nativeIssueId: childIssueId,
nativeSessionId: identity.sessionId,
runnerInstanceId,
completionContractId: localContractId,
completionContractSha256: "child-wake-contract",
contextSnapshot: { issueId: childIssueId },
});
const port = new PaperclipControlPlanePort(db, {
companyId: identity.companyId,
issueId: childIssueId,
runId,
agentId: identity.agentId,
sessionId: identity.sessionId,
completionContractId: localContractId,
completionContractSha256: "child-wake-contract",
sourceInstanceId: runnerInstanceId,
controlPlaneSourceInstanceId: "child-wake-control",
});
await port.openRun({
identity: { ...identity, issueId: childIssueId, runId },
backendKind: "mock",
sourceInstanceId: runnerInstanceId,
});
const result: PrpStructuredRunResult = {
...structuredClone(CONTROL_PLANE_CONFORMANCE_RESULT),
summary: `Implemented revision ${revision} and passed 44/44 tests.`,
completionClaim: {
contractRevision: "child-wake-v1",
objectiveSatisfied: true,
criteria: [{ criterionId: "objective", status: "satisfied", evidenceRefs: [] }],
remainingWork: [],
},
verification: [{ commandOrCheck: "node --test", status: "passed" }],
attentionRequests: [],
};
await port.completeRun({
result,
terminal: CONTROL_PLANE_CONFORMANCE_TERMINAL,
callerResultId: "child-wake-result",
});
await finalizeNativeRun({ db, runId, workspaceFinalizeStatus: "succeeded" });
const [completedChild] = await db.select().from(issues).where(eq(issues.id, childIssueId));
expect(completedChild).toMatchObject({ status: "done", statusVersion: revision === 1 ? 1 : 3 });
const parentWakes = await db.select().from(agentWakeupRequests)
.where(eq(agentWakeupRequests.companyId, identity.companyId))
.then((rows) => rows.filter((row) => row.payload?.issueId === parentIssueId));
expect(parentWakes).toHaveLength(1);
expect(parentWakes[0]).toMatchObject({
reason: "issue_children_completed",
payload: {
issueId: parentIssueId,
completedChildIssueId: childIssueId,
childIssueIds: [childIssueId],
childIssueSummaries: [{
id: childIssueId,
title: "Implement the accepted plan",
status: "done",
summary: "Implemented the accepted plan and passed 44/44 tests.",
}],
childIssueSummaryTruncated: false,
_paperclipWakeContext: {
wakeReason: "issue_children_completed",
const parentWakes = await db.select().from(agentWakeupRequests)
.where(eq(agentWakeupRequests.companyId, identity.companyId))
.then((rows) => rows.filter((row) => row.payload?.issueId === parentIssueId));
const wakeCount = originKind === "task_watchdog" ? 1 : revision;
expect(parentWakes).toHaveLength(wakeCount);
const latestWake = parentWakes.find((wake) => wake.status === "queued");
if (originKind === "task_watchdog" && revision === 2) {
expect(latestWake).toBeUndefined();
} else expect(latestWake).toMatchObject({
reason: "issue_children_completed",
payload: {
issueId: parentIssueId,
completedChildIssueId: childIssueId,
childIssueIds: [childIssueId],
childIssueSummaries: [{
id: childIssueId,
summary: "Implemented the accepted plan and passed 44/44 tests.",
title: "Implement the accepted plan",
status: "done",
summary: `Implemented revision ${revision} and passed 44/44 tests.`,
}],
childIssueSummaryTruncated: false,
_paperclipWakeContext: {
wakeReason: "issue_children_completed",
childIssueSummaries: [{
id: childIssueId,
summary: `Implemented revision ${revision} and passed 44/44 tests.`,
}],
},
},
},
});
});
if (latestWake) {
const delivery = latestWake.payload?._paperclipWakeContext as Record<string, unknown>;
expect(delivery.nativeChildCompletionDecisionId).toBe(
originKind === "task_watchdog" ? undefined : completedChild!.lastStatusDecisionId,
);
}
await finalizeNativeRun({ db, runId, workspaceFinalizeStatus: "succeeded" });
const replayWakes = await db.select().from(agentWakeupRequests)
.where(eq(agentWakeupRequests.companyId, identity.companyId))
.then((rows) => rows.filter((row) => row.payload?.issueId === parentIssueId));
expect(replayWakes).toHaveLength(wakeCount);
expect(new Set(replayWakes.map((wake) => wake.idempotencyKey)).size).toBe(wakeCount);
}
});
it("returns a rejected native completion review to the original agent with the reviewer reason", async () => {
@@ -44,6 +44,21 @@ function arbitrate(
}
describe("native status authority", () => {
it("keeps a pending child result non-terminal without adding another continuation", () => {
expect(arbitrate({ hasPendingChildCompletion: true })).toMatchObject({
statusAction: "in_progress", toStatus: "in_progress", reasonCode: "native_child_completion_pending",
effects: [{ kind: "release_checkout" }],
});
expect(arbitrate({ hasPendingChildCompletion: false }).toStatus).toBe("done");
for (const priorIssueStatus of ["done", "cancelled"] as const) {
expect(arbitrate({ priorIssueStatus, hasPendingChildCompletion: true }).toStatus).toBe(priorIssueStatus);
}
expect(arbitrate({ hasPendingChildCompletion: true, hasUnresolvedIssueBlockers: true }).toStatus).toBe("blocked");
expect(arbitrate({ hasPendingChildCompletion: true, governanceGate: { kind: "approval", id: "approval" } }).toStatus)
.toBe("in_review");
expect(arbitrate({ hasPendingChildCompletion: true, workspaceFinalizeStatus: "failed" }).statusAction).toBe("preserve");
});
it("schedules a capacity retry while preserving partial work and review authority", () => {
for (const nativeReviewOutcome of [undefined, "pending"] as const) {
const decision = arbitrate({ terminalState: "failed", providerOverloaded: true, nativeReviewOutcome });
@@ -547,7 +562,7 @@ describe("native status authority", () => {
expect.objectContaining({
statusAction: "blocked",
toStatus: "blocked",
policyVersion: "phase6-v9",
policyVersion: "phase6-v10",
reasonCode: "current_track_blocker_waiting",
unblockDescriptor: {
owner: "board",
@@ -1,7 +1,7 @@
import type { NativeEvidenceAssessment } from "./evidence-classifier.js";
import { NATIVE_MODEL_REJECTION_MESSAGE, NATIVE_PROVIDER_CAPACITY_MAX_RETRIES, NATIVE_PROVIDER_OVERLOADED_CODE, NATIVE_PROVIDER_OVERLOADED_MESSAGE } from "./native-provider-failure.js";
export const NATIVE_STATUS_ARBITER_POLICY_VERSION = "phase6-v9";
export const NATIVE_STATUS_ARBITER_POLICY_VERSION = "phase6-v10";
export type NativeAuthoritativeIssueStatus =
| "backlog"
@@ -105,6 +105,8 @@ export function arbitrateNativeStatus(input: {
* checkout is intentionally gated until the dependency resolves.
*/
hasUnresolvedIssueBlockers?: boolean;
/** A newer native child result still has an undelivered parent wake. */
hasPendingChildCompletion?: boolean;
/** A governance interaction created by this run was accepted before the run settled. */
governanceResolvedForRun?: boolean;
externalChatResponseWaitAuthorization?:
@@ -363,6 +365,17 @@ export function arbitrateNativeStatus(input: {
input.assessment.attentionRequests.length === 0 &&
!input.assessment.hasBlockingRemainingWork;
const complete = evidenceComplete || policyClaimComplete;
if (complete && input.hasPendingChildCompletion) {
return {
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
statusAction: "in_progress",
toStatus: "in_progress",
reasonCode: "native_child_completion_pending",
unblockDescriptor: null,
// The durable outbox/deferred wake already owns exactly one continuation.
effects: [{ kind: "release_checkout" }],
};
}
if (complete) {
return {
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
@@ -1,3 +1,4 @@
import { hasPendingNativeChildCompletion, type NativeChildCompletionRecipient } from "./native-child-completion-delivery.js";
import { and, asc, desc, eq, inArray, notInArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import type { CreateIssueThreadInteraction } from "@paperclipai/shared";
@@ -1546,6 +1547,7 @@ export async function commitNativeStatusDecision(input: {
preMaterializedEffects?: NativeMaterializedStatusEffect[];
supersedesCommittedDecisionId?: string;
requireExternalChatResponseWaitAuthorization?: { agentId: string };
requireNoPendingChildCompletion?: NativeChildCompletionRecipient;
requireModelRejectionOwner?: { agentId: string; reviewContext: NativeReviewAssignmentContext | null };
requireProviderFailureOwner?: { agentId: string; reviewContext: NativeReviewAssignmentContext | null };
requireBoardResponseWaitSource?: NativeBoardResponseWaitSource;
@@ -1635,6 +1637,11 @@ export async function commitNativeStatusDecision(input: {
) {
throw new NativeStatusRaceError();
}
// Recheck under the parent status lock: a child can finish between the
// finalizer's read and this commit. Re-arbitrate instead of discarding its wake.
if (input.requireNoPendingChildCompletion && await hasPendingNativeChildCompletion(
tx as unknown as Db, input.requireNoPendingChildCompletion,
)) throw new NativeStatusRaceError();
if (input.requireModelRejectionOwner || input.requireProviderFailureOwner) {
const owner = (input.requireProviderFailureOwner ?? input.requireModelRejectionOwner)!;
// A successor can claim execution without changing statusVersion. Keep
@@ -1903,6 +1910,16 @@ export async function commitNativeStatusDecision(input: {
);
}
if (input.decision.statusAction === "done" && issue.status !== "done" &&
issue.parentId && issue.originKind !== "task_watchdog") {
// Serialize child notification creation with the parent's final pending-
// result check. Lock before child writes can acquire an implicit parent
// foreign-key lock, so concurrent siblings never upgrade shared locks.
await tx.select({ id: issues.id }).from(issues).where(and(
eq(issues.id, issue.parentId), eq(issues.companyId, input.companyId),
)).for("update");
}
const materialized: NativeMaterializedStatusEffect[] = [];
const preMaterializedEffects = new Map(
(input.preMaterializedEffects ?? []).map((effect) => [
@@ -1999,10 +2016,20 @@ export async function commitNativeStatusDecision(input: {
const parentIsDependent = parent
? dependents.some((dependent) => dependent.id === parent.id)
: false;
// Ordinary children can finish new work after reopening. Key that wake to
// the committed decision; replaying the decision still shares its identity.
// Watchdog children retain their stable key to avoid feedback loops.
const childCompletionDecisionId = issue.originKind === "task_watchdog" ? null : decisionRow.id;
const childCompletionRevision = childCompletionDecisionId ? `:${childCompletionDecisionId}` : "";
// The dispatcher must retain this new input until a fresh parent turn can
// read it. Watchdog handoffs keep their existing coalescing semantics.
const childCompletionDelivery = childCompletionDecisionId
? { nativeChildCompletionDecisionId: childCompletionDecisionId }
: {};
for (const dependent of dependents) {
const isCompletedChildParent = parent?.id === dependent.id;
const idempotencyKey = isCompletedChildParent
? `issue_children_completed:${dependent.id}:${input.issueId}`
? `issue_children_completed:${dependent.id}:${input.issueId}${childCompletionRevision}`
: buildIssueBlockersResolvedWakeIdempotencyKey({
dependentIssueId: dependent.id,
resolvedBlockerIssueId: input.issueId,
@@ -2011,6 +2038,7 @@ export async function commitNativeStatusDecision(input: {
isCompletedChildParent && parent
? {
completedChildIssueId: input.issueId,
...childCompletionDelivery,
childIssueIds: parent.childIssueIds,
childIssueSummaries: parent.childIssueSummaries,
onboardingCompletion: parent.onboardingCompletion,
@@ -2053,9 +2081,10 @@ export async function commitNativeStatusDecision(input: {
issueId: parent.id,
agentId: parent.assigneeAgentId,
reason: "issue_children_completed",
idempotencyKey: `issue_children_completed:${parent.id}:${input.issueId}`,
idempotencyKey: `issue_children_completed:${parent.id}:${input.issueId}${childCompletionRevision}`,
payload: {
completedChildIssueId: input.issueId,
...childCompletionDelivery,
childIssueIds: parent.childIssueIds,
childIssueSummaries: parent.childIssueSummaries,
onboardingCompletion: parent.onboardingCompletion,
@@ -2063,6 +2092,7 @@ export async function commitNativeStatusDecision(input: {
},
contextSnapshot: {
completedChildIssueId: input.issueId,
...childCompletionDelivery,
childIssueIds: parent.childIssueIds,
childIssueSummaries: parent.childIssueSummaries,
onboardingCompletion: parent.onboardingCompletion,
@@ -2076,6 +2106,7 @@ export async function commitNativeStatusDecision(input: {
payload: {
parentIssueId: parent.id,
completedChildIssueId: input.issueId,
...childCompletionDelivery,
childIssueSummaries: parent.childIssueSummaries,
onboardingCompletion: parent.onboardingCompletion,
childIssueSummaryTruncated: parent.childIssueSummaryTruncated,
+37 -1
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { expect, test } from "@playwright/test";
import { createPlanTaskThroughUi } from "../runner-e2e/plan-task-ui.js";
import { createPlanTaskThroughUi, createTaskFromPromptThroughUi } from "../runner-e2e/plan-task-ui.js";
test("planning fixture creates the exact assignment through the current composer without providers", async ({ page, request }) => {
const companyResponse = await request.post("/api/companies", { data: { name: `Planning setup ${randomUUID()}` } });
@@ -30,3 +30,39 @@ test("planning fixture creates the exact assignment through the current composer
expect((await request.delete(`/api/companies/${company.id}`)).ok()).toBe(true);
}
});
test("everyday fixture preserves prompt, selected project and assignee without providers", async ({ page, request }) => {
const companyResponse = await request.post("/api/companies", { data: { name: `Everyday setup ${randomUUID()}` } });
expect(companyResponse.ok()).toBe(true);
const company = await companyResponse.json();
try {
let lead: { id: string; name: string } | undefined;
for (const name of ["A Different Owner", "Everyday Fixture Lead"]) {
const response = await request.post(`/api/companies/${company.id}/agents`, { data: {
name, role: "general", adapterType: "process", adapterConfig: { command: "false" },
runtimeConfig: { heartbeat: { enabled: false } },
} });
expect(response.ok()).toBe(true);
lead = await response.json();
expect((await request.post(`/api/agents/${lead!.id}/pause`)).ok()).toBe(true);
}
let project: { id: string; name: string } | undefined;
for (const name of ["A Different Project", "Everyday Fixture Project"]) {
const response = await request.post(`/api/companies/${company.id}/projects`, { data: { name } });
expect(response.ok()).toBe(true);
project = await response.json();
}
const prompt = "Delegate the implementation to an engineer, review its delivered files, and finish after the revised result is ready.";
const created = await createTaskFromPromptThroughUi({ page, companyId: company.id, issuePrefix: company.issuePrefix,
agentName: lead!.name, projectName: project!.name, prompt });
const issue = await (await request.get(`/api/issues/${created.id}`)).json();
expect(issue).toMatchObject({ id: created.id, companyId: company.id, assigneeAgentId: lead!.id,
projectId: project!.id, description: prompt, workMode: "standard" });
const runs = await request.get(`/api/companies/${company.id}/heartbeat-runs?limit=100`);
expect(runs.ok()).toBe(true);
expect(await runs.json()).toEqual([]);
} finally {
expect((await request.delete(`/api/companies/${company.id}`)).ok()).toBe(true);
}
});
+5 -14
View File
@@ -13,7 +13,8 @@ import {
} from "./everyday-delivery.js";
import type { LiveFixtureValues } from "./live-fixtures.js";
import type { MatrixExecution } from "./types.js";
import { createTaskThroughUi, submitTaskReply } from "./user-actions.js";
import { submitTaskReply } from "./user-actions.js";
import { createTaskFromPromptThroughUi } from "./plan-task-ui.js";
import { waitForTaskChatRendered } from "./continuation-screenshot.js";
import { hasPersistedSource, isSavedSourceCheckpoint } from "./everyday-interruption.js";
import { setupAggregatorFixture } from "./aggregator-fixture.js";
@@ -667,25 +668,15 @@ export async function runEverydayFlow(input: Input) {
if (state.connections.length)
throw new Error("New-connection story requires an unconnected company");
}
await createTaskThroughUi({
const created = await createTaskFromPromptThroughUi({
page,
companyId: fixtures.company.id,
issuePrefix: prefix,
agentName: fixtures.agent.name,
title: execution.task.buildTitle(nonce),
prompt: ev.prompt,
workMode: "standard",
projectName: project?.name,
});
parent = await pollUntil({
label: "browser-created story task",
deadlineAt: Date.now() + 30_000,
load: () =>
api.get<StoryIssue[]>(`/api/companies/${fixtures.company.id}/issues`),
accept: (rows) =>
rows.some((i) => i.title === execution.task.buildTitle(nonce)),
}).then((rows) =>
rows.find((i) => i.title === execution.task.buildTitle(nonce))!,
);
parent = await api.get<StoryIssue>(`/api/issues/${created.id}`);
input.observe(parent!, []);
note("task-submitted", { issueId: parent!.id });
await openParent();
+15 -2
View File
@@ -3,8 +3,8 @@ import { expect, type Page } from "@playwright/test";
/** The current composer creates a task from its description and assigns its
* title later. Capture the actual public create response; never guess its ID
* from a title or mutate the issue behind the browser. */
export async function createPlanTaskThroughUi(input: {
page: Page; companyId: string; issuePrefix: string; agentName: string; prompt: string;
export async function createTaskFromPromptThroughUi(input: {
page: Page; companyId: string; issuePrefix: string; agentName: string; prompt: string; projectName?: string;
}): Promise<{ id: string }> {
const { page } = input;
await page.goto(`/${encodeURIComponent(input.issuePrefix)}/issues`, { waitUntil: "domcontentloaded" });
@@ -18,6 +18,14 @@ export async function createPlanTaskThroughUi(input: {
await expect(option).toHaveCount(1);
await option.click();
await expect(assignee).toContainText(input.agentName);
if (input.projectName) {
const project = dialog.getByRole("group", { name: "Task project and worktrees", exact: true }).getByRole("button").first();
// Focus opens this selector without toggling an already-open popover closed.
await project.focus();
await page.getByPlaceholder("Search projects...", { exact: true }).fill(input.projectName);
await page.getByRole("button", { name: input.projectName, exact: true }).click();
await expect(project).toContainText(input.projectName);
}
const responsePromise = page.waitForResponse(response => response.request().method() === "POST" &&
new URL(response.url()).pathname === `/api/companies/${input.companyId}/issues`);
await dialog.getByRole("button", { name: "Create task", exact: true }).click();
@@ -25,5 +33,10 @@ export async function createPlanTaskThroughUi(input: {
if (!response.ok()) throw new Error(`Browser task creation failed: ${response.status()}`);
const issue = await response.json();
if (typeof issue.id !== "string" || issue.companyId !== input.companyId) throw new Error("Browser created an unexpected issue identity");
if (issue.description !== input.prompt || issue.workMode !== "standard") {
throw new Error("Browser task creation changed the requested prompt or work mode");
}
return { id: issue.id };
}
export const createPlanTaskThroughUi = createTaskFromPromptThroughUi;