Skip to content

Commit 6410b19

Browse files
authored
Recover Sidekick without replaying interrupted work (#1057)
* Add parent-owned headless XMD sessions * Describe XMD session capture values in shipped syntax * Compare attached child bindings and history with ACP reads * Verify child UI acknowledgements after actual frame application * Reject child UI coordinates from unapplied presentations * Annotate XMD response schemas for JSR publishing * Scope attached UI test tracking to its owning operation * Complete the four missing headless-child acceptance witnesses Add the late-UI-question-after-embedded-answer case, a lost B-notification case, a malformed B-result (consumeTurn parse failure) case, and a stock ACPX permission exchange, closing the acceptance gaps named in the J3/H5, H6/J5, H6, and J4 correction. Extend the test-support fixture with an optional per-event transform hook so a test can corrupt or drop one ACP runtime event without touching production code. * test: exercise invalid and delayed headless UI submissions * style: format headless client regression tests * test: resolve stock ACPX through its package and assert shipped profile * build: preserve supplied declarations in npm packages * fix: release retired inputs and detached renderer generations * Implement explicit Sidekick recovery and child reference handoff * Fix interrupted recovery preparation and retain held-job outcomes * Make recovery actions readable * Keep recovery tray allocation on whole rows
1 parent c397ea5 commit 6410b19

30 files changed

Lines changed: 4448 additions & 171 deletions

‎architecture.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6518,6 +6518,26 @@ pure and answers with the exact objects that model holds.
65186518

65196519
### Local Sidekick attachments
65206520

6521+
The recovery records stay with the CLI Sidekick owner. `sidekick/recovery-record.ts`
6522+
parses the closed decision and reference facts; `sidekick/recovery-state.ts`
6523+
checks their exact request and offer linkage during history projection.
6524+
`sidekick/recovery-owner.ts` reads the existing logical-session ownership
6525+
administration for the captured provider identity. None of these facts repairs
6526+
canonical Prompt history or establishes old backend acceptance.
6527+
6528+
Deliberate recovery fallback in a child prepares a different conversation in
6529+
the same root and publishes a fresh exact reference. The original stays selected
6530+
while its UI shows the actual attach command and one-field session reference.
6531+
Semantic saved-reference acknowledgement is retained before canonical activation;
6532+
an applied frame establishes no such acknowledgement. The existing child manager's
6533+
private `handoffReference` operation owns publication and reads actual canonical
6534+
selection when its continuation unwinds. An unused association is retired; an
6535+
acknowledged selected association survives continuation failure. The old endpoint
6536+
and its UI service and screen lease join before the bound new instruction runs.
6537+
Old references refuse attachment and selection, retain the owning parent's
6538+
Shutdown route, and never follow the new conversation. Every association ends
6539+
with this one child. Prepared Sidekick attachments keep their original rights.
6540+
65216541
A headless XMD child publishes an opaque address association containing only
65226542
`version`, `transport`, `socket`, `owner`, `execution`, `conversation` and `cwd`.
65236543
The public sessionId identifies that live root and exact selected conversation;

‎packages/cli/src/documents/sidekick/recovery.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,21 @@ prepare.
1111

1212
## Instructions
1313

14+
- When the person chooses recovery, treat the supplied new instruction as a
15+
distinct request in the original conversation. Earlier feedback acceptance
16+
remains unknown. Do not resend that feedback, re-evaluate its response, or
17+
submit established work again. Read the retained entry results before
18+
proposing the next step.
19+
- A failed or cancelled provider turn runs no response controls, even when its
20+
saved text contains valid XMD. Keep its failure and partial text as evidence.
21+
- Only the person can choose a different conversation. In a parent-owned child,
22+
that choice first shows an actual fresh attachment command and
23+
`{sessionId: "<fresh-sessionId>"}`. The original conversation stays selected
24+
until the person chooses `[saved reference; continue]`. Applying the receipt
25+
frame or losing the UI connection does not acknowledge it. Continuing
26+
disconnects that UI; explicit attachment with the saved reference reaches the
27+
same live REPL and sends nothing. The old reference never follows the new
28+
conversation; the owning parent can still use it for Shutdown.
1429
- Two different records exist. This conversation's history is what was said and
1530
what your controls returned. The REPL's entry History is what was *run*. They
1631
answer different questions, and neither is a summary of the other.

‎packages/cli/src/headless/host.ts‎

Lines changed: 187 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { childRuntime } from "./runtime.ts";
2-
import { ensure, Err, Ok, scoped, useScope, withResolvers } from "effection";
2+
import { ensure, Err, Ok, scoped, suspend, useScope, withResolvers } from "effection";
33
import type { Operation, Result, Task } from "effection";
44
import { ensureDir } from "@effectionx/fs";
55
import { Agent, content, hasContent } from "@executablemd/core";
@@ -49,7 +49,21 @@ interface Child {
4949
close(): Operation<void>;
5050
readonly path: string;
5151
readonly descriptor: Descriptor;
52+
endpoint: ChildEndpoint;
5253
}
54+
interface ChildEndpoint {
55+
readonly task: Task<void>;
56+
readonly closed: Operation<Result<void>>;
57+
reported?: Error;
58+
}
59+
function* stopEndpoint(endpoint: ChildEndpoint): Operation<void> {
60+
yield* endpoint.task.halt();
61+
const closed = yield* endpoint.closed;
62+
if (!closed.ok && closed.error !== endpoint.reported) {
63+
throw closed.error;
64+
}
65+
}
66+
5367
interface Allocation {
5468
readonly sessionId: string;
5569
readonly fresh: boolean;
@@ -66,11 +80,12 @@ export function* useHeadlessChildren(
6680
): Operation<HeadlessChildren> {
6781
const parent = yield* useScope();
6882
const live = new Map<string, Child>();
83+
const associations = new Map<string, { child: Child; conversation: string; path: string }>();
6984
const issued = new WeakMap<object, string>();
7085
let ended = false;
7186

7287
function owned(sessionId: string): Child {
73-
const child = live.get(sessionId);
88+
const child = live.get(sessionId) ?? associations.get(sessionId)?.child;
7489
if (ended || child === undefined) {
7590
throw new Error(
7691
`XMD child ${sessionId} is unavailable or ended; no replacement was started.`,
@@ -85,7 +100,8 @@ export function* useHeadlessChildren(
85100
if (!reading.ok) {
86101
throw reading.error;
87102
}
88-
if (reading.value.active !== child.descriptor.conversation) {
103+
const conversation = associations.get(sessionId)?.conversation ?? child.descriptor.conversation;
104+
if (reading.value.active !== conversation) {
89105
throw new Error(
90106
`XMD child ${sessionId} no longer selects its original Sidekick conversation.`,
91107
);
@@ -94,7 +110,7 @@ export function* useHeadlessChildren(
94110
}
95111

96112
function* acquire(allocation: Allocation): Operation<Child> {
97-
const known = live.get(allocation.sessionId);
113+
const known = live.get(allocation.sessionId) ?? associations.get(allocation.sessionId)?.child;
98114
if (known !== undefined) {
99115
return lookup(allocation.sessionId);
100116
}
@@ -121,28 +137,173 @@ export function* useHeadlessChildren(
121137
try {
122138
yield* scoped(function* () {
123139
const view = yield* useReplLiveScreen();
140+
const childScope = yield* useScope();
141+
const endpoints: ChildEndpoint[] = [];
142+
yield* ensure(function* () {
143+
const failures: Error[] = [];
144+
for (const endpoint of endpoints) {
145+
try {
146+
yield* stopEndpoint(endpoint);
147+
} catch (error) {
148+
failures.push(error instanceof Error ? error : new Error(String(error)));
149+
}
150+
}
151+
if (failures.length > 0) {
152+
throw new AggregateError(failures, "Child endpoint cleanup failed.");
153+
}
154+
});
155+
function publishEndpoint(
156+
root: Root,
157+
path: string,
158+
sessionId: string,
159+
conversation?: string,
160+
available?: () => boolean,
161+
) {
162+
const ready = withResolvers<Result<Descriptor>>();
163+
const closed = withResolvers<Result<void>>();
164+
const task = childScope.run(function* () {
165+
let failure: Error | undefined;
166+
try {
167+
yield* scoped(function* () {
168+
ready.resolve(
169+
Ok(
170+
yield* useSidekickAttachment(
171+
path,
172+
{
173+
owner: root.owner,
174+
dispatch: root.dispatch,
175+
reads: root.reads,
176+
execution: root.execution,
177+
cwd: root.cwd,
178+
...(available === undefined ? {} : { available }),
179+
child: { sessionId, session: root.session, view },
180+
},
181+
conversation,
182+
),
183+
),
184+
);
185+
yield* suspend();
186+
});
187+
} catch (error) {
188+
failure = error instanceof Error ? error : new Error(String(error));
189+
ready.resolve(Err(failure));
190+
} finally {
191+
closed.resolve(failure === undefined ? Ok(undefined) : Err(failure));
192+
}
193+
});
194+
const service: ChildEndpoint = { task, closed: closed.operation };
195+
endpoints.push(service);
196+
return { service, ready: ready.operation };
197+
}
198+
let registered: Child | undefined;
199+
let pendingReference = false;
124200
yield* start({
125201
view,
202+
handoffReference(change, acknowledgeAndActivate) {
203+
return scoped(function* () {
204+
const child = registered;
205+
if (child === undefined) {
206+
return Err(new Error("The child is not registered."));
207+
}
208+
if (pendingReference) {
209+
return Err(new Error("This child already has a reference handoff in progress."));
210+
}
211+
yield* ensure(() => {
212+
pendingReference = false;
213+
});
214+
pendingReference = true;
215+
const reading = child.root.owner.reading;
216+
if (!reading.ok) {
217+
return reading;
218+
}
219+
const decision = reading.value.recoveries.find(
220+
(one) => one.decision.id === change.decision,
221+
)?.decision;
222+
const next = reading.value.conversations.find((one) => one.id === change.next);
223+
if (
224+
reading.value.active !== change.previous ||
225+
decision?.action !== "replace" ||
226+
decision.conversation !== change.previous ||
227+
decision.target !== change.next ||
228+
next?.phase !== "prepared" ||
229+
next.previous !== change.previous
230+
) {
231+
return Err(
232+
new Error(
233+
"The child reference change no longer matches its acknowledged decision and preparation.",
234+
),
235+
);
236+
}
237+
const sessionId = (yield* SidekickTransport.expect()).identify();
238+
const reference = { sessionId };
239+
const path = yield* childAddressPath(sessionId);
240+
let endpoint: ChildEndpoint | undefined;
241+
let available = false;
242+
yield* ensure(function* () {
243+
const selected = child.root.owner.reading;
244+
if (!selected.ok) {
245+
throw selected.error;
246+
}
247+
if (selected.value.active === change.next) {
248+
if (endpoint !== undefined) {
249+
associations.set(sessionId, { child, conversation: change.next, path });
250+
const previous = child.endpoint;
251+
child.endpoint = endpoint;
252+
yield* stopEndpoint(previous);
253+
available = true;
254+
}
255+
} else if (endpoint !== undefined) {
256+
yield* stopEndpoint(endpoint);
257+
}
258+
});
259+
const publication = publishEndpoint(
260+
child.root,
261+
path,
262+
sessionId,
263+
change.next,
264+
() => available,
265+
);
266+
endpoint = publication.service;
267+
const published = yield* publication.ready;
268+
if (!published.ok) {
269+
endpoint.reported = published.error;
270+
return published;
271+
}
272+
const activated = yield* acknowledgeAndActivate(reference);
273+
if (!activated.ok) {
274+
return activated;
275+
}
276+
const selected = child.root.owner.reading;
277+
if (!selected.ok) {
278+
return selected;
279+
}
280+
if (selected.value.active !== change.next) {
281+
return Err(new Error("The fresh reference was not canonically selected."));
282+
}
283+
return Ok(reference);
284+
});
285+
},
126286
*ready(root) {
127287
const selected = yield* root.owner.configure({});
128288
if (!selected.ok) {
129289
throw selected.error;
130290
}
131291
const path = yield* childAddressPath(allocation.sessionId);
132-
const descriptor = yield* useSidekickAttachment(path, {
133-
owner: root.owner,
134-
dispatch: root.dispatch,
135-
reads: root.reads,
136-
execution: root.execution,
137-
cwd: root.cwd,
138-
child: { sessionId: allocation.sessionId, session: root.session, view },
139-
});
292+
const publication = publishEndpoint(root, path, allocation.sessionId);
293+
const published = yield* publication.ready;
294+
if (!published.ok) {
295+
publication.service.reported = published.error;
296+
throw published.error;
297+
}
298+
const descriptor = published.value;
299+
const endpoint = publication.service;
140300
const child: Child = {
141301
sessionId: allocation.sessionId,
142302
root,
143303
view,
144304
path,
145305
descriptor,
306+
endpoint,
146307
task: childTask,
147308
*close() {
148309
yield* childTask.halt();
@@ -152,6 +313,7 @@ export function* useHeadlessChildren(
152313
}
153314
},
154315
};
316+
registered = child;
155317
live.set(child.sessionId, child);
156318
established = true;
157319
ready.resolve(Ok(child));
@@ -172,6 +334,11 @@ export function* useHeadlessChildren(
172334
return Err(error);
173335
} finally {
174336
live.delete(allocation.sessionId);
337+
for (const [id, association] of associations) {
338+
if (association.child.sessionId === allocation.sessionId) {
339+
associations.delete(id);
340+
}
341+
}
175342
closed.resolve(failure === undefined ? Ok(undefined) : Err(failure));
176343
}
177344
});
@@ -218,12 +385,10 @@ export function* useHeadlessChildren(
218385
}
219386
},
220387
createRuntime(runtimeOptions) {
221-
return childRuntime(
222-
runtimeOptions,
223-
createRuntime,
224-
providerScope,
225-
(id) => lookup(id).path,
226-
);
388+
return childRuntime(runtimeOptions, createRuntime, providerScope, (id) => {
389+
const child = lookup(id);
390+
return associations.get(id)?.path ?? child.path;
391+
});
227392
},
228393
sessions: {
229394
*requestSession(request) {
@@ -245,9 +410,10 @@ export function* useHeadlessChildren(
245410
}
246411
const identity = request.sessionIdentity;
247412
if (identity === undefined) {
248-
const selected = lookup(reference.parse({ sessionId: request.session }).sessionId);
413+
const sessionId = reference.parse({ sessionId: request.session }).sessionId;
414+
const selected = lookup(sessionId);
249415
return {
250-
sessionKey: selected.sessionId,
416+
sessionKey: sessionId,
251417
cwd: selected.root.cwd,
252418
state: "existing",
253419
};
@@ -272,7 +438,7 @@ export function* useHeadlessChildren(
272438
};
273439
},
274440
*recordSessionIdentity(details, identity) {
275-
if (live.has(details.sessionKey)) {
441+
if (live.has(details.sessionKey) || associations.has(details.sessionKey)) {
276442
lookup(details.sessionKey);
277443
} else if (dependencies.sessions !== undefined) {
278444
yield* dependencies.sessions.recordSessionIdentity(details, identity);

0 commit comments

Comments
 (0)