Skip to content

Commit 42ffc7d

Browse files
committed
Borrowing: track detached session reattachment
1 parent 1a8b2a2 commit 42ffc7d

19 files changed

Lines changed: 384 additions & 17 deletions

docs/openclawcode/borrowing-delivery-plan.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,10 +158,18 @@ Completed in this wave:
158158
- `tasks show` / `flows show` now also expose `lookup` and `resolvedBy`, so
159159
detail views explain which operator token matched the underlying durable
160160
record
161+
- foreground `sessions continue` now stamps related task and TaskFlow records
162+
with durable `reattachedAt` metadata, so detached work can later distinguish
163+
"still backgrounded" from "brought back to the foreground"
164+
- that reattach metadata now shows up in `tasks show`, `flows show`, and
165+
`sessions show` related-record payloads, giving operator views a first
166+
durable hook for later completion-routing behavior
161167

162168
Staged next:
163169

164170
- non-CLI reattach affordances
171+
- completion routing and notifications that react differently once detached
172+
work has been foreground-reattached
165173
- clearer remote-session lifecycle views in operator UIs
166174

167175
### 5. Bridge and remote-control subsystem boundaries

docs/reference/claudecode-borrowing.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -332,15 +332,22 @@ Implemented:
332332
- `openclaw tasks show` and `openclaw flows show` now also expose `lookup` and
333333
`resolvedBy`, so machine-readable detail views keep the same explicit
334334
resolution context as `sessions show`
335+
- foreground `openclaw sessions continue ...` now also stamps related detached
336+
task and TaskFlow records with durable `reattachedAt` metadata, so later
337+
operator views can tell whether the detached session stayed detached or was
338+
explicitly brought back to the foreground
339+
- that `reattachedAt` state now surfaces in `tasks show`, `flows show`, and
340+
`sessions show` related-record output, which establishes the substrate needed
341+
for ClaudeCode-style completion routing that depends on reattach state
335342

336343
Not implemented yet:
337344

338345
- a richer "background my currently interactive TUI/Control-UI turn" affordance
339346
beyond the CLI entrypoint
340347
- session transcript handoff and recovery UX matching ClaudeCode's
341348
`LocalMainSessionTask` pattern
342-
- completion-routing behavior that depends on whether a detached task was
343-
later reattached
349+
- the actual completion-routing behavior that depends on whether a detached
350+
task was later reattached
344351

345352
This keeps the first step deliberately narrow: make detached-session provenance
346353
durable and inspectable first, then build the foreground-to-background

src/commands/flows.show.detached-lifecycle.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ describe("flowsShowCommand detached session lifecycle", () => {
155155
expect(output).toContain("resumeTranscript: n/a");
156156
expect(output).toContain("resumeTranscriptExists: no");
157157
expect(output).toContain("continueWith: openclaw sessions continue agent:coder:acp:child");
158+
expect(output).toContain("reattachedAt: n/a");
158159

159160
const jsonRun = makeRuntime();
160161
await flowsShowCommand({ lookup: flow.flowId, json: true }, jsonRun.runtime);
@@ -195,6 +196,26 @@ describe("flowsShowCommand detached session lifecycle", () => {
195196
});
196197
});
197198

199+
it("shows reattachedAt when the detached flow was later resumed in foreground", async () => {
200+
const reattachedAt = Date.parse("2026-04-03T11:58:30Z");
201+
const flow = createManagedTaskFlow({
202+
ownerKey: "agent:coder:acp:owner-reattached",
203+
controllerId: "tests/flows-show",
204+
goal: "Continue detached work",
205+
status: "waiting",
206+
reattachedAt,
207+
});
208+
209+
const textRun = makeRuntime();
210+
await flowsShowCommand({ lookup: flow.flowId, json: false }, textRun.runtime);
211+
expect(textRun.logs.join("\n")).toContain("reattachedAt: 2026-04-03T11:58:30.000Z");
212+
213+
const jsonRun = makeRuntime();
214+
await flowsShowCommand({ lookup: flow.flowId, json: true }, jsonRun.runtime);
215+
const payload = JSON.parse(jsonRun.logs[0] ?? "{}") as { reattachedAt?: number };
216+
expect(payload.reattachedAt).toBe(reattachedAt);
217+
});
218+
198219
it("classifies an owner-key lookup in JSON output", async () => {
199220
const flow = createManagedTaskFlow({
200221
ownerKey: "agent:coder:acp:owner-lookup",

src/commands/flows.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,7 @@ export async function flowsShowCommand(
326326
...(flow.cancelRequestedAt
327327
? [`cancelRequestedAt: ${new Date(flow.cancelRequestedAt).toISOString()}`]
328328
: []),
329+
`reattachedAt: ${flow.reattachedAt ? new Date(flow.reattachedAt).toISOString() : "n/a"}`,
329330
`createdAt: ${new Date(flow.createdAt).toISOString()}`,
330331
`updatedAt: ${new Date(flow.updatedAt).toISOString()}`,
331332
`endedAt: ${flow.endedAt ? new Date(flow.endedAt).toISOString() : "n/a"}`,

src/commands/sessions.continue.test.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import fs from "node:fs";
22
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import { configureTaskFlowRegistryRuntime } from "../tasks/task-flow-registry.store.js";
4+
import { createManagedTaskFlow, getTaskFlowById, resetTaskFlowRegistryForTests } from "../tasks/task-flow-runtime-internal.js";
5+
import { configureTaskRegistryRuntime } from "../tasks/task-registry.store.js";
6+
import { createTaskRecord, getTaskById, resetTaskRegistryForTests } from "../tasks/runtime-internal.js";
37
import { makeRuntime, mockSessionsConfig, writeStore } from "./sessions.test-helpers.js";
48

59
const mocks = vi.hoisted(() => ({
@@ -15,9 +19,37 @@ mockSessionsConfig();
1519
import { sessionsContinueCommand } from "./sessions.js";
1620

1721
describe("sessionsContinueCommand", () => {
22+
const taskStore = {
23+
loadSnapshot: () => ({
24+
tasks: new Map(),
25+
deliveryStates: new Map(),
26+
}),
27+
saveSnapshot: () => {},
28+
upsertTaskWithDeliveryState: () => {},
29+
upsertTask: () => {},
30+
deleteTaskWithDeliveryState: () => {},
31+
deleteTask: () => {},
32+
upsertDeliveryState: () => {},
33+
deleteDeliveryState: () => {},
34+
close: () => {},
35+
};
36+
const flowStore = {
37+
loadSnapshot: () => ({
38+
flows: new Map(),
39+
}),
40+
saveSnapshot: () => {},
41+
upsertFlow: () => {},
42+
deleteFlow: () => {},
43+
close: () => {},
44+
};
45+
1846
beforeEach(() => {
1947
vi.clearAllMocks();
2048
mocks.agentCliCommandMock.mockResolvedValue({});
49+
resetTaskRegistryForTests({ persist: false });
50+
resetTaskFlowRegistryForTests({ persist: false });
51+
configureTaskRegistryRuntime({ store: taskStore });
52+
configureTaskFlowRegistryRuntime({ store: flowStore });
2153
});
2254

2355
it("wraps JSON output with resolved session metadata and forwarded agent result", async () => {
@@ -303,4 +335,107 @@ describe("sessionsContinueCommand", () => {
303335
fs.rmSync(store, { force: true });
304336
}
305337
});
338+
339+
it("marks related tasks and flows as reattached for foreground continue", async () => {
340+
const store = writeStore({
341+
"agent:coder:acp:child": {
342+
sessionId: "sess-child-continue-reattach",
343+
updatedAt: Date.now() - 5 * 60_000,
344+
},
345+
});
346+
const task = createTaskRecord({
347+
runtime: "acp",
348+
ownerKey: "agent:main:main",
349+
scopeKind: "session",
350+
childSessionKey: "agent:coder:acp:child",
351+
originKind: "detached_session",
352+
originSessionKey: "agent:main:main",
353+
task: "Detached child",
354+
status: "running",
355+
deliveryStatus: "pending",
356+
notifyPolicy: "state_changes",
357+
});
358+
const flow = createManagedTaskFlow({
359+
ownerKey: "agent:coder:acp:child",
360+
controllerId: "tests/sessions-continue",
361+
goal: "Detached child",
362+
status: "waiting",
363+
});
364+
365+
try {
366+
const { runtime, logs } = makeRuntime();
367+
await sessionsContinueCommand(
368+
{
369+
lookup: "sess-child-continue-reattach",
370+
message: "Bring this back to foreground",
371+
store,
372+
json: true,
373+
},
374+
runtime,
375+
);
376+
377+
const updatedTask = getTaskById(task.taskId);
378+
const updatedFlow = getTaskFlowById(flow.flowId);
379+
expect(updatedTask?.reattachedAt).toBeTypeOf("number");
380+
expect(updatedFlow?.reattachedAt).toBeTypeOf("number");
381+
382+
const payload = JSON.parse(logs[0] ?? "{}") as {
383+
continuedSession?: { reattachedAt?: number | null };
384+
};
385+
expect(payload.continuedSession?.reattachedAt).toBe(updatedTask?.reattachedAt);
386+
expect(updatedFlow?.reattachedAt).toBe(updatedTask?.reattachedAt);
387+
} finally {
388+
fs.rmSync(store, { force: true });
389+
}
390+
});
391+
392+
it("does not mark related tasks and flows as reattached for background continue", async () => {
393+
const store = writeStore({
394+
"agent:coder:acp:bg-child": {
395+
sessionId: "sess-child-continue-bg",
396+
updatedAt: Date.now() - 5 * 60_000,
397+
},
398+
});
399+
const task = createTaskRecord({
400+
runtime: "acp",
401+
ownerKey: "agent:main:main",
402+
scopeKind: "session",
403+
childSessionKey: "agent:coder:acp:bg-child",
404+
originKind: "detached_session",
405+
originSessionKey: "agent:main:main",
406+
task: "Detached child",
407+
status: "running",
408+
deliveryStatus: "pending",
409+
notifyPolicy: "state_changes",
410+
});
411+
const flow = createManagedTaskFlow({
412+
ownerKey: "agent:coder:acp:bg-child",
413+
controllerId: "tests/sessions-continue",
414+
goal: "Detached child",
415+
status: "waiting",
416+
});
417+
418+
try {
419+
const { runtime, logs } = makeRuntime();
420+
await sessionsContinueCommand(
421+
{
422+
lookup: "sess-child-continue-bg",
423+
message: "Keep it detached",
424+
store,
425+
json: true,
426+
background: true,
427+
},
428+
runtime,
429+
);
430+
431+
expect(getTaskById(task.taskId)?.reattachedAt).toBeUndefined();
432+
expect(getTaskFlowById(flow.flowId)?.reattachedAt).toBeUndefined();
433+
const payload = JSON.parse(logs[0] ?? "{}") as {
434+
continuedSession?: { reattachedAt?: number | null };
435+
};
436+
expect(payload.continuedSession?.reattachedAt).toBeNull();
437+
} finally {
438+
fs.rmSync(store, { force: true });
439+
}
440+
});
306441
});

src/commands/sessions.show.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,12 +181,14 @@ describe("sessionsShowCommand", () => {
181181
{
182182
runId: "run-detached-1",
183183
originKind: "detached_session",
184+
reattachedAt: null,
184185
},
185186
],
186187
relatedTaskFlows: [
187188
{
188189
controllerId: "tests/sessions-show",
189190
status: "waiting",
191+
reattachedAt: null,
190192
},
191193
],
192194
resume: {
@@ -210,6 +212,61 @@ describe("sessionsShowCommand", () => {
210212
);
211213
});
212214

215+
it("shows reattached metadata on related tasks and flows", async () => {
216+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sessions-show-reattached-"));
217+
tempDirs.push(root);
218+
const store = path.join(root, "sessions.json");
219+
fs.writeFileSync(
220+
store,
221+
JSON.stringify(
222+
{
223+
"agent:coder:acp:child-reattached": {
224+
sessionId: "sess-child-reattached",
225+
updatedAt: Date.now() - 5 * 60_000,
226+
},
227+
},
228+
null,
229+
2,
230+
),
231+
"utf8",
232+
);
233+
234+
const reattachedAt = Date.parse("2026-04-03T11:58:30Z");
235+
createTaskRecord({
236+
runtime: "acp",
237+
ownerKey: "agent:main:main",
238+
scopeKind: "session",
239+
childSessionKey: "agent:coder:acp:child-reattached",
240+
originKind: "detached_session",
241+
originSessionKey: "agent:main:main",
242+
task: "Continue detached work",
243+
status: "running",
244+
deliveryStatus: "pending",
245+
notifyPolicy: "state_changes",
246+
reattachedAt,
247+
});
248+
createManagedTaskFlow({
249+
ownerKey: "agent:coder:acp:child-reattached",
250+
controllerId: "tests/sessions-show",
251+
goal: "Continue detached work",
252+
status: "waiting",
253+
reattachedAt,
254+
});
255+
256+
const { runtime, logs } = makeRuntime();
257+
await sessionsShowCommand(
258+
{
259+
lookup: "sess-child-reattached",
260+
store,
261+
json: false,
262+
},
263+
runtime,
264+
);
265+
266+
const output = logs.join("\n");
267+
expect(output).toContain("[reattached 2026-04-03T11:58:30.000Z]");
268+
});
269+
213270
it("classifies a session with a missing transcript as missing_transcript", async () => {
214271
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sessions-show-missing-"));
215272
tempDirs.push(root);

0 commit comments

Comments
 (0)