Skip to content

Commit 32d7399

Browse files
authored
Merge pull request #53 from vitry/fix/zcode-0165-permission-turn
fix: retain managed turn through late permissions
2 parents 15cb6bb + 1f40454 commit 32d7399

17 files changed

Lines changed: 775 additions & 73 deletions

docs/superpowers/plans/2026-09-01-zcode-0165-permission-turn-lifecycle.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
**Architecture:** Add a non-destructive completion observer alongside the existing destructive waiter in the protocol/client layer. Migrate only `executeJob` to that observer and explicitly release its local turn during unconditional teardown; retain compatibility fallbacks for injected test clients that expose only the historical interface.
88

9+
**Follow-up architecture:** Carry that lifecycle boundary through the managed broker. The broker forwards legacy completion without consuming its upstream turn, and the managed client acknowledges the executor's authoritative cleanup through an exact-owner broker control request. Also accept the observed bounded `requestedAt` field on 0.16.5 permission requests.
10+
911
**Tech Stack:** Node.js 22.13, ECMAScript modules, `node:test`, the existing JSON-RPC protocol client and job executor.
1012

1113
---
@@ -17,6 +19,10 @@
1719
- Modify `scripts/lib/review.mjs`: use non-destructive observation for `legacyWake` and release the local turn during teardown.
1820
- Modify `tests/process-zcode.test.mjs`: cover low-level observer, permission, timeout, release, and destructive-wait invariants.
1921
- Modify `tests/job-control.test.mjs`: cover captured 0.16.5 executor ordering and success/error cleanup.
22+
- Modify `scripts/zcode-broker.mjs`: retain the upstream route through legacy wake and release it only on exact managed-client acknowledgement or existing authoritative cleanup.
23+
- Modify `tests/zcode-client.test.mjs`: cover broker ordering, acknowledgement ownership, cleanup, and permission forwarding.
24+
- Modify `tests/fixtures/fake-zcode-cli.mjs`: emit the captured 0.16.5 permission shape after the false legacy completion.
25+
- Modify `tests/integration/companion.test.mjs`: cover fresh and resumed production managed paths.
2026

2127
### Task 1: Add non-destructive completion observation
2228

@@ -216,3 +222,40 @@ git commit -m "build: refresh marketplace snapshot"
216222
```
217223

218224
If there are no generated tracked changes, skip this commit.
225+
226+
### Task 4: Reproduce the managed-broker and 0.16.5 schema failures
227+
228+
**Files:**
229+
- Test: `tests/zcode-client.test.mjs`
230+
- Test fixture: `tests/fixtures/fake-zcode-cli.mjs`
231+
- Test: `tests/process-zcode.test.mjs`
232+
233+
- [ ] Add an opt-in captured fixture permission request after the false legacy completion and before the authoritative v4 terminal. Include the real numeric `requestedAt` field.
234+
- [ ] Add a direct protocol test proving the current validator rejects that captured field.
235+
- [ ] Add a managed broker test proving the early completion currently removes the route and the later permission is not allowed.
236+
- [ ] Run only these tests and record deterministic RED output before changing production code.
237+
238+
### Task 5: Retain and explicitly acknowledge managed broker turns
239+
240+
**Files:**
241+
- Modify: `scripts/lib/zcode-protocol.mjs`
242+
- Modify: `scripts/lib/zcode-client.mjs`
243+
- Modify: `scripts/zcode-broker.mjs`
244+
- Test: `tests/zcode-client.test.mjs`
245+
- Test: `tests/process-zcode.test.mjs`
246+
247+
- [ ] Accept only bounded optional `requestedAt` permission timestamps.
248+
- [ ] Replace broker-side destructive terminal consumption with forwarding/non-destructive observation that retains the exact upstream turn.
249+
- [ ] Add an authenticated exact-session broker acknowledgement used by managed `releaseTurn()`. It locally releases the upstream turn, settles exact pending permissions, removes the route, and is idempotent for the same completed downstream cleanup without stopping the ZCode session or changing durable ownership.
250+
- [ ] Reject foreign, stale, malformed, and conflicting acknowledgements without touching a newer turn.
251+
- [ ] Run focused tests to GREEN, self-review, and commit.
252+
253+
### Task 6: Lock the production fresh/resumed path and re-verify release readiness
254+
255+
**Files:**
256+
- Modify: `tests/integration/companion.test.mjs`
257+
- Modify if required by generated parity checks: marketplace snapshot through the existing builder only
258+
259+
- [ ] Extend the captured 0.16.5 managed integration to require an allowed permission response after the false legacy completion for both fresh and resumed sends.
260+
- [ ] Run focused protocol, broker, executor, and companion tests.
261+
- [ ] Run `npm run check`, review the complete follow-up diff, refresh the marketplace snapshot if required, and complete spec then quality review before pushing PR #52.

docs/superpowers/specs/2026-09-01-zcode-0165-permission-turn-lifecycle-design.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,25 @@ The executor already treats ZCode 0.16.5 `state.updated` notifications with reas
66

77
ZCode 0.16.5 can emit that legacy notification immediately after admission, then request tool permission while the real runtime turn is still active. Once the legacy waiter deletes the turn, the permission request fails the exact-active-turn check and is returned as JSON-RPC `-32000` (`ZCODE_PERMISSION_SESSION_INVALID`). This is independent of the configured Codex permission mode: the incident job already carried `bypassPermissions`.
88

9+
### Follow-up incident: managed broker boundary
10+
11+
The first implementation preserved the executor-side broker client turn, but production uses two protocol layers. The managed broker still registered `consumeTerminalsWith()` on its upstream appserver connection. That callback consumed the same admission-time legacy notification, settled permission routes, and deleted the broker's active session before the executor could perform authoritative v4/snapshot reconciliation. The resumed incident therefore failed before `decidePermission()` was reached even though the persisted snapshot was `bypassPermissions` and the installed plugin matched the fix byte-for-byte.
12+
13+
ZCode 0.16.5 permission requests also carry a numeric `requestedAt` field. The captured fixture omitted it and the strict protocol validator rejected it. Compatibility must accept this bounded transport metadata while continuing to reject unknown fields.
14+
915
## Goals
1016

1117
- Keep the exact turn armed after an admission-time legacy completion wake so later permission requests can be evaluated normally.
1218
- Preserve the current destructive behavior of `waitForCompletion()` for every existing caller.
1319
- Clear local turn state when `executeJob` reaches its real terminal or cleanup boundary.
20+
- Keep the managed broker's upstream turn and permission route alive until the executor confirms its authoritative terminal/cleanup boundary.
21+
- Accept the observed 0.16.5 `requestedAt` permission-request field.
1422
- Cover the captured 0.16.5 event ordering with deterministic regression tests.
1523

1624
## Non-goals
1725

1826
- No change to `decidePermission`, permission snapshots, risk mapping, or offered-response validation.
19-
- No new appserver flag, permission field, broker authorization rule, or persisted schema.
27+
- No new appserver flag, permission policy, broker ownership rule, or persisted schema.
2028
- No reinterpretation of legacy completion as authoritative success or failure.
2129
- No broad refactor of the protocol or executor lifecycle.
2230

@@ -38,10 +46,23 @@ Because the wake no longer consumes local state, `executeJob` must explicitly re
3846

3947
The cleanup operation is local and idempotent. It must not send `session/stop`, alter durable job state, or replace the existing cancellation paths. Successful terminal, provider failure, remote interruption, local abort, timeout, and error cleanup all converge on the same local turn release when a session was created or resumed.
4048

49+
### Managed broker terminal acknowledgement
50+
51+
For a direct appserver client, `releaseTurn()` remains local. For an authenticated managed-broker client, release first sends a narrow broker-only acknowledgement for the exact session turn, then clears the downstream local turn. The broker validates session ownership and the exact active socket/token, locally releases the corresponding upstream protocol turn, settles only that turn's pending permission tasks, and removes its active route. It must not call `session/stop`, release durable session ownership, or accept a stale/foreign acknowledgement.
52+
53+
The broker no longer treats a legacy `prompt_completed` notification as authority to delete its route. It forwards the validated notification to the active client as a wake and retains the upstream turn. Existing authoritative stop, owner-release, disconnect, protocol-close, and explicit terminal-acknowledgement paths remain responsible for cleanup.
54+
55+
### Permission request compatibility
56+
57+
The strict request validator accepts optional `requestedAt` only when it is a finite, non-negative safe integer timestamp. All existing required fields, risk levels, option validation, exact offered-response validation, and unknown-field rejection remain unchanged. The captured 0.16.5 fixture includes this field so broker and direct-client tests exercise the production request shape.
58+
4159
### Safety invariants
4260

4361
- A validated early legacy completion leaves `turnState(sessionId) === 'armed'`.
4462
- A later permission request for that session reaches the configured handler and returns one offered response.
63+
- A managed broker retains the exact route after an early legacy completion and forwards a later permission request.
64+
- Only the exact owning client can acknowledge and release the broker turn.
65+
- A 0.16.5 request with bounded `requestedAt` is accepted; malformed timestamps and unknown fields remain rejected.
4566
- Authoritative executor teardown leaves `turnState(sessionId) === null`.
4667
- Ordinary `waitForCompletion()` still leaves `turnState(sessionId) === null` immediately after resolution.
4768
- Permission policy and durable job outcomes are unchanged.
@@ -53,6 +74,8 @@ The cleanup operation is local and idempotent. It must not send `session/stop`,
5374
3. Add an executor regression with captured 0.16.5 ordering: admission, early legacy wake, later permission request, authoritative terminal, successful result, and final local turn cleanup.
5475
4. Exercise cleanup on a non-success path so an observer cannot leave an armed turn behind.
5576
5. Run focused tests, then `npm run check` before review and PR creation.
77+
6. Add a broker-level captured-order regression where permission follows the false legacy completion, and assert explicit acknowledgement releases both protocol layers.
78+
7. Exercise fresh and resumed managed execution with the same captured ordering.
5679

5780
## Rollout and compatibility
5881

marketplace/.agents/plugins/provenance.json

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
{
22
"packageVersion": "0.1.0",
33
"pluginVersion": "0.1.0",
4-
"sourceRef": "ee1fa5c9778cf6dd02e094cf2bfe7f2835c02dab",
5-
"sourceSha": "ee1fa5c9778cf6dd02e094cf2bfe7f2835c02dab",
4+
"sourceRef": "9b1b65af0703dfeb885759ec174049a2f3d1f343",
5+
"sourceSha": "9b1b65af0703dfeb885759ec174049a2f3d1f343",
66
"dependencyLock": {
77
"file": "npm-shrinkwrap.json",
88
"sha256": "fa927194e6ca0b25c1d3f428859b2ab4798b8eabb4d31bc87188d73c630f9938"
99
},
1010
"content": {
1111
"algorithm": "sha256",
12-
"sha256": "40d1a65be6976f5f070b2784a35635f5aba4597c4846ec798b86d3f30dd77af7",
12+
"sha256": "04443494d3f15c34e2d2f92acebea3a133d97cd9cab41f9034140e33e85d3020",
1313
"files": [
1414
{
1515
"path": ".agents/plugins/marketplace.json",
@@ -658,8 +658,8 @@
658658
},
659659
{
660660
"path": "plugins/zcode/scripts/lib/review.mjs",
661-
"size": 43572,
662-
"sha256": "0d945d223ce618ee28e2e81831ce855df1b18cc975f45dcc5538b8cc6e934d11"
661+
"size": 44075,
662+
"sha256": "8575a666fadd8022373e6c54ea4aabe7e3af708d1d64d002d9da85dc454378c1"
663663
},
664664
{
665665
"path": "plugins/zcode/scripts/lib/session-progress.mjs",
@@ -703,8 +703,8 @@
703703
},
704704
{
705705
"path": "plugins/zcode/scripts/lib/zcode-client.mjs",
706-
"size": 52495,
707-
"sha256": "0d4c854d4045260cc86d14d30e647db22efb08bd4f9ad30a2f795c3b5e37f901"
706+
"size": 57159,
707+
"sha256": "0f6542ca1d80f44745b1f1ff7d3f065db24b0e9f14060a99a174728b2cb7e604"
708708
},
709709
{
710710
"path": "plugins/zcode/scripts/lib/zcode-discovery.mjs",
@@ -713,8 +713,8 @@
713713
},
714714
{
715715
"path": "plugins/zcode/scripts/lib/zcode-protocol.mjs",
716-
"size": 42018,
717-
"sha256": "68e98ef8be8b670648e3add7966fb1619b3836be0ce6b2a29e678899f68f9426"
716+
"size": 43295,
717+
"sha256": "d01d37715518b956ed6ff6b2d0f1fafadc51c9c4998ce3617f14ef64230ba409"
718718
},
719719
{
720720
"path": "plugins/zcode/scripts/lib/zcode-runtime-config.mjs",
@@ -728,8 +728,8 @@
728728
},
729729
{
730730
"path": "plugins/zcode/scripts/zcode-broker.mjs",
731-
"size": 103199,
732-
"sha256": "3861c818720c569ac483c1b6488d0165e039f392d1ab9a5186e2175a71008a08"
731+
"size": 107670,
732+
"sha256": "c85c04fb48bacae4ca41d937ec9de0905d25fdb7f99bd76e4c122a876bd673ba"
733733
},
734734
{
735735
"path": "plugins/zcode/scripts/zcode-companion.mjs",

marketplace/plugins/zcode/scripts/lib/review.mjs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -342,12 +342,21 @@ export async function executeJob(input) {
342342
}
343343
// Cleanup order is part of the progress lifecycle contract.
344344
await cleanupProgress();
345-
try {
346-
if (sessionId && typeof client.releaseTurn === 'function') client.releaseTurn(sessionId);
347-
} catch (cleanupError) {
348-
if (!primaryError && output?.job?.status !== 'succeeded') primaryError = cleanupError;
345+
let releaseError;
346+
if (sessionId && typeof client.releaseTurn === 'function') {
347+
for (let attempt = 0; attempt < 2; attempt += 1) {
348+
try { await client.releaseTurn(sessionId); releaseError = undefined; break; }
349+
catch (cleanupError) { releaseError = cleanupError; }
350+
}
351+
}
352+
if (releaseError) {
353+
await jobLog?.appendBlock('Cleanup diagnostic', 'ZCode turn release cleanup was incomplete.', Date.now() + OPTIONAL_PROGRESS_FENCE_MS).catch(() => {});
354+
if (!primaryError && output?.job?.status !== 'succeeded') primaryError = releaseError;
355+
}
356+
try { await client.close(); }
357+
catch {
358+
await jobLog?.appendBlock('Cleanup diagnostic', 'ZCode client close cleanup was incomplete.', Date.now() + OPTIONAL_PROGRESS_FENCE_MS).catch(() => {});
349359
}
350-
await client.close().catch(() => {});
351360
if (!primaryError && appliedFinalization && output?.job?.status === 'succeeded' && typeof output.result === 'string') {
352361
await jobLog?.appendBlock('Final output', output.result, Date.now() + OPTIONAL_PROGRESS_FENCE_MS);
353362
}

0 commit comments

Comments
 (0)