Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b65119e
feat(harness): define neutral subsession delegation contracts
ynadge Sep 4, 2026
185b63d
feat(harness): persist fenced subsession claims
ynadge Sep 4, 2026
a8b0cc9
feat(harness): recover reserved writable sessions
ynadge Sep 4, 2026
6a7f609
feat(harness): coordinate writable project subsessions
ynadge Sep 4, 2026
a99a83f
feat(harness): expose subsession delegation to every agent
ynadge Sep 4, 2026
b84d878
feat(harness): recover bound subsession runtimes
ynadge Sep 4, 2026
9b14572
fix(harness): fence codex rollout ownership
ynadge Sep 4, 2026
006cd7f
merge: sync SAP-3150 focused brief fixes
ynadge Sep 4, 2026
1f9fd2f
test(harness): complete writable subsession acceptance
ynadge Sep 4, 2026
0cd41b9
fix(harness): bound subsession recovery state
ynadge Sep 4, 2026
a67fc46
fix(harness): expire subsession recovery history
ynadge Sep 4, 2026
3a1ea26
fix(harness): reclaim terminal subsession bindings
ynadge Sep 4, 2026
ac7a22b
fix(harness): preserve resumable subsession bindings
ynadge Sep 4, 2026
97dc184
fix(harness): classify subsession capacity exhaustion
ynadge Sep 4, 2026
d00469d
feat(harness): release owned project subsessions
ynadge Sep 4, 2026
fa5d2f9
fix(harness): bound subsession release recovery
ynadge Sep 4, 2026
7efe577
fix(harness): reclaim orphaned dormant subsessions
ynadge Sep 4, 2026
78ea86d
fix(harness): make dormant recovery project-scoped
ynadge Sep 4, 2026
a66e4d8
fix(harness): expire dormant delegation identities
ynadge Sep 4, 2026
ca74723
fix(harness): report dormant eviction outcomes
ynadge Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .changeset/writable-project-subsessions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
"@sapiom/harness": minor
---

Add capability-scoped `project_subsession_delegate` support for creating,
reusing, or releasing bounded batches of ordinary writable project sessions. Delegations use
durable parent/key bindings, canonical request digests, transactional spawn and
kickoff claims, exact focused-context references, readiness-gated delivery,
restart recovery, nested common-tool composition, and real session IDs.

**Breaking for embedders** (minor while `@sapiom/harness` is pre-1.0): internal
session hosts that construct the Agent Map MCP router must provide the shared
`SubsessionCoordinator`; session hosts that tail transcript-backed adapters must
also complete exact runtime identity correlation before trusted background
kickoff. No REST delegation endpoint or model-controlled project/session
selector is added.

Manual sessions remain outside coordinator ownership. Consumers should treat
`uncertain` kickoff delivery as terminal until an exact persisted
acknowledgement arrives, and should use a new request/delegation key when the
corresponding canonical content changes. Nested delegation is bounded to four
levels and 64 active or explicitly re-referenced coordinator-owned sessions per
project. A parent can idempotently release its own child bindings by delegation
key, closing the exact coordinator-owned Harness session and recovering
capacity without granting access to manual or foreign sessions; unknown keys
converge as already released. After the coordinator close is durable, private
SessionManager ownership proof is pruned so release churn remains bounded
across restart. Request, binding, and acknowledged-delivery history use bounded
retention so long-lived projects do not dead-end on routine delegation, release,
or context refreshes. Exited and failed bindings remain durable for resume or
recovery without holding an active slot until re-referenced, or until explicitly
released. Any current project agent may explicitly reclaim up to sixteen dormant
coordinator-owned bindings in its project without supplying raw session IDs.
Each child is atomically rechecked as exited or failed; parent liveness is
intentionally irrelevant. This destructive recovery compacts coordinator and
private ownership state while retaining the ordinary Harness session history;
the released binding is no longer automatically resumable. Durable-history
capacity failures identify `release_dormant` in their recovery field, while an
all-active live cap does not suggest dormant cleanup. Delegation retries converge
until this explicit project-wide eviction boundary. Eviction expires request
receipts that referenced the released binding, so an old retry returns bounded
`request_key_expired` / `new_request_key` recovery; a fresh request key may
atomically create one new binding and real session for the same delegation key.
Dormant eviction emits content-free release telemetry when it commits. If later
private-marker cleanup fails, the result remains truthfully `released`, includes
the bounded cleanup error, and retains exact proof for idempotent cleanup after
the indicated recovery.
75 changes: 75 additions & 0 deletions packages/harness/docs/shared-build-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,78 @@ and passing the branded `projection` through `TrustedSessionCreateOptions` or
project-agent identity. Ordinary callers cannot construct the branded value,
and authored data must never be appended to a prompt by another serialization
path.

## Writable project subsessions

Every ordinary project session discovers `project_subsession_delegate` beside
the shared map, plan, and brief tools. The operation creates, reuses, or
releases one to sixteen ordinary writable sessions. Each child receives the same common
project-agent prompt, coding capabilities, project tools, and delegation tool,
so nested delegation follows the same path. An exact assignment, map node, or
brief may focus the child, but focus never changes its tools or authority.
Delegation is bounded to four levels and 64 active or explicitly re-referenced
coordinator-owned sessions per project. Dormant exited or failed bindings retain
their exact resume identity without holding an active slot until they are
re-referenced. A parent can idempotently release its own child bindings by
delegation key to close their real Harness sessions and recover capacity; it
cannot name arbitrary session IDs or release another parent's or a manual
session. Unknown or expired keys converge as already released without exposing
a session identity.

Callers provide both a request key and a delegation key. Identity is scoped by
the private session capability to the trusted project and parent session.
Identical retries converge on the same durable binding and real Harness session
ID until an explicit project-wide dormant eviction; changing canonical request
or binding content under an existing key otherwise fails explicitly. All binding
IDs and session IDs for a bounded batch are reserved in one durable transaction
before the first process is spawned.
Older request receipts compact into bounded key tombstones. User-closed
bindings compact into bounded ownership tombstones once no retained receipt
references them; an explicit release finalizes immediately to the same
tombstone while its receipt retains deterministic replay. Exited and failed
bindings remain available for the coordinator's ordinary resume and recovery
paths until explicitly released. Once the durable coordinator close succeeds,
SessionManager prunes the exact private ownership marker and close tombstone; a
failed final cleanup retains that proof for the next idempotent retry. The
oldest tombstones expire as the retention window advances, so routine
delegation, release, and focused-context refreshes cannot permanently exhaust a
project.
Proven acknowledged or unsent delivery epochs are likewise pruned when a newer
focused-context delivery replaces them; ambiguous delivery evidence is retained.

If exited or failed bindings fill durable binding history, any current project
session may explicitly invoke the bounded `release-dormant` operation. The
coordinator selects at most sixteen eligible records inside the
capability-derived project; the request accepts no session IDs and never selects
active bindings or manual sessions. Parent liveness is intentionally irrelevant:
this explicit project-wide destructive operation relinquishes dormant delegation
resume identity even when the original parent is active. It retains the ordinary
Harness conversation/session history, but compacts the coordinator binding and
ends automatic resume through that binding. The sweep remains idempotent and
restart-safe, while prior request receipts referencing an evicted binding become
bounded expiry tombstones. Retrying one of those keys returns
`request_key_expired` with `new_request_key`; a fresh request key may atomically
create one new binding/session for the same delegation key. Durable-history
capacity errors expose the explicit `release_dormant` recovery code; an
all-active live cap continues to require session inspection instead of suggesting
an inapplicable dormant cleanup. A bounded private-marker cleanup error may
accompany an already-`released` result because eviction is durable first. Exact
cleanup proof remains available so the same sweep can finish after the indicated
recovery or inspection without changing the release outcome.

The coordinator waits for canonical adapter readiness and exact transcript
identity, then uses fenced spawn and delivery epochs to submit one kickoff.
Delivery states distinguish pending, claimed, submitted without acknowledgement,
acknowledged, and uncertain. An uncertain delivery is never resent blindly.
Exact focused references are checked before delivery, and stale context returns
an explicit refresh path without closing the session or changing writability.

Coordinator recovery starts from its own two-sided private binding marker. It
does not infer ownership from cwd, title, assignment, map membership, or process
similarity, and it never adopts, renames, resumes, closes, or removes an
unrelated manual session. Tabs remain projections of ordinary live sessions,
deduplicated by the real session ID and exact server-derived project identity.

Delegation telemetry contains only event names, project/session identifiers,
and bounded error codes. Task text, kickoff context, focused prose, source,
paths, secrets, credentials, and raw adapter output are excluded.
150 changes: 150 additions & 0 deletions packages/harness/src/core/collector/codex-rollout-broker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";

import { CodexRolloutBroker } from "./codex-rollout-broker.js";

const meta = (id: string, cwd: string, timestamp: string) =>
`${JSON.stringify({ type: "session_meta", payload: { id, cwd, timestamp } })}\n`;

describe("CodexRolloutBroker", () => {
const roots: string[] = [];

afterEach(async () => {
await Promise.all(
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
});

async function fixture() {
const home = await mkdtemp(join(tmpdir(), "codex-rollout-broker-"));
const cwd = join(home, "project");
const sessions = join(home, ".codex", "sessions", "2026", "09", "04");
await Promise.all([mkdir(cwd), mkdir(sessions, { recursive: true })]);
roots.push(home);
return { home, cwd, sessions };
}

it("uniquely assigns concurrent same-root rollouts by process epoch", async () => {
const { home, cwd, sessions } = await fixture();
const firstTime = Date.parse("2026-09-04T10:00:00.000Z");
const secondTime = Date.parse("2026-09-04T10:00:01.000Z");
const firstPath = join(sessions, "rollout-first.jsonl");
const secondPath = join(sessions, "rollout-second.jsonl");
await writeFile(
firstPath,
meta("agent-first", cwd, "2026-09-04T10:00:00.500Z"),
);
await writeFile(
secondPath,
meta("agent-second", cwd, "2026-09-04T10:00:01.500Z"),
);
const broker = new CodexRolloutBroker(home);
broker.register({
sessionId: "first",
runtimeEpoch: "runtime-1",
cwd,
sinceMs: firstTime,
});
broker.register({
sessionId: "second",
runtimeEpoch: "runtime-2",
cwd,
sinceMs: secondTime,
});

await expect(
broker.claimFresh({
sessionId: "first",
runtimeEpoch: "runtime-1",
cwd,
sinceMs: firstTime,
}),
).resolves.toEqual({ outcome: "claimed", path: firstPath });
await expect(
broker.claimFresh({
sessionId: "second",
runtimeEpoch: "runtime-2",
cwd,
sinceMs: secondTime,
}),
).resolves.toEqual({ outcome: "claimed", path: secondPath });
});

it("fails closed when same-root process epochs cannot distinguish candidates", async () => {
const { home, cwd, sessions } = await fixture();
const sinceMs = Date.parse("2026-09-04T10:00:00.000Z");
await writeFile(
join(sessions, "rollout-a.jsonl"),
meta("agent-a", cwd, "2026-09-04T10:00:01.000Z"),
);
await writeFile(
join(sessions, "rollout-b.jsonl"),
meta("agent-b", cwd, "2026-09-04T10:00:02.000Z"),
);
const broker = new CodexRolloutBroker(home);
broker.register({
sessionId: "first",
runtimeEpoch: "runtime-1",
cwd,
sinceMs,
});
broker.register({
sessionId: "second",
runtimeEpoch: "runtime-2",
cwd,
sinceMs,
});

await expect(
broker.claimFresh({
sessionId: "first",
runtimeEpoch: "runtime-1",
cwd,
sinceMs,
}),
).resolves.toEqual({ outcome: "ambiguous", path: null });
await expect(
broker.claimFresh({
sessionId: "second",
runtimeEpoch: "runtime-2",
cwd,
sinceMs,
}),
).resolves.toEqual({ outcome: "ambiguous", path: null });
});

it("allows only the same Harness session to reclaim an exact rollout on resume", async () => {
const { home, cwd, sessions } = await fixture();
const rolloutPath = join(sessions, "rollout-resume.jsonl");
await writeFile(
rolloutPath,
meta("agent-resume", cwd, "2026-09-04T10:00:01.000Z"),
);
const broker = new CodexRolloutBroker(home);
const base = { cwd, sinceMs: 0, agentSessionId: "agent-resume" };
await expect(
broker.claimExact({
...base,
sessionId: "owner",
runtimeEpoch: "runtime-1",
}),
).resolves.toEqual({ outcome: "claimed", path: rolloutPath });
broker.release("owner", "runtime-1");
await expect(
broker.claimExact({
...base,
sessionId: "owner",
runtimeEpoch: "runtime-2",
}),
).resolves.toEqual({ outcome: "claimed", path: rolloutPath });
await expect(
broker.claimExact({
...base,
sessionId: "foreign",
runtimeEpoch: "runtime-3",
}),
).resolves.toEqual({ outcome: "pending", path: null });
});
});
Loading
Loading