mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 13:24:55 +01:00
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:
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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user