mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
feat(daemon): session_source: { mode: rebuild } (Option A: artifact-set seat)
Add Option A of the Seat Continuity and Handover primitive: a v1 narrow
MVP for `session_source: { mode: rebuild, ref: { kind: artifact_set,
value: [paths...] } }` declared at member-level in a rig spec or
`rig expand` payload. Fresh-launches the harness and seeds the running
TUI with operator-declared artifacts in trust-precedence order
(highest-trust first: rig CULTURE, role doc, handover packet, queue
files, session logs).
Identity-honesty bedrock: the rebuild seat persists with NO
`resumeToken` and `continuityOutcome: "rebuilt"` (NEVER `"fresh"`,
`"resumed"`, or `"forked"`). Verified at four layers — schema dispatch,
orchestrator threading (no `resumeToken`, no `forkSource`),
SQLite read-back (sessions.resume_token IS NULL), and negative-grep on
resolver output JSON (no `restored`/`resumed`/`snapshot`/`forked` tags).
Schema (types, rigspec-schema, rigspec-codec):
- `SessionSourceSpec` refactored from a single fork-only shape to a
discriminated union `SessionSourceForkSpec | SessionSourceRebuildSpec`
so consumers can typed-narrow on `mode`. Existing fork consumers
unchanged.
- `validateSessionSource` dispatches on `mode` and delegates to either
`validateForkSessionSource` (existing behavior, sharper "v1 fork
mode" wording) or `validateRebuildSessionSource` (new). Rebuild
rejects: terminal runtime; non-`artifact_set` ref.kind (with
cross-mode hint when the kind belongs to fork); empty/non-array
`ref.value`; empty-string entries within `ref.value`.
- `normalizeSessionSource` likewise dispatches and produces a typed
`SessionSourceSpec` for either mode.
- Codec serializer's `ref.value` slot already accepts the array shape
via the YAML library; commented for clarity.
Expansion (routes/rigs):
- `normalizeExpansionPodFragment` now reads both `mode: fork` and
`mode: rebuild` shapes from the `/api/rigs/:id/expand` body.
Resolver (NEW: session-source-rebuild-resolver):
- `resolveRebuildArtifacts(spec, opts)` loads each declared path,
preserves operator-declared trust-precedence order, records missing
paths as gaps, and refuses (with a clear error citing trust-
precedence order as guidance) only when ALL declared paths fail to
resolve. The function does NOT execute, parse, or evaluate artifact
contents — it just records that a path exists and hands metadata to
the orchestrator's existing `deliverStartup` seam. Pluggable
`exists` injection seam for unit tests.
Orchestrator (startup-orchestrator):
- `StartupResult.continuityOutcome` literal widened to include
`"rebuilt"` (the broader `ContinuityOutcome` union in `types.ts`
already included it from L2 lifecycle work).
- `StartupInput.rebuildArtifacts?: ResolvedStartupFile[]` added.
- When `rebuildArtifacts` is set, the artifacts are prepended to
`resolvedStartupFiles` so the existing partition + delivery path
handles them in operator order.
- `launchHarness` is called with NO `resumeToken` and NO `forkSource`
on rebuild — fresh-launch shape. `continuityOutcome` is set to
`"rebuilt"`.
Instantiator (rigspec-instantiator):
- Dispatches on `member.sessionSource?.mode`: `"fork"` builds the
`forkSource` opt (existing behavior); `"rebuild"` resolves artifacts
via the new resolver and passes them as `rebuildArtifacts`. If the
resolver refuses (no paths resolved), the instantiator returns
`{status: "failed", error: <resolver error>}` before calling
`startNode`.
Out of scope for Option A (per slice IMPL): the full code-direct seat
handover (Option B / Mode 3 sub-mission); `SeatHandoverService` daemon
module; `rig seat handover` / `rig occupant rebuild` CLI commands;
nodes-table schema migration (HARD GATE on audit row 4 cleared:
`continuityOutcome: "rebuilt"` already in the enum); live session-
binding mutation; cross-host work; auto-discovery of artifacts
(operator declares paths in `ref.value` in trust-precedence order);
content execution or evaluation from artifacts in the resolver.
Tests (Tier 1, in-package mocks only — no real Claude / Codex; no
host `~/.claude` / `~/.codex` / `~/.openrig` mutation; resolver real-
filesystem smoke uses `mktempSync` temp paths only):
- `test/session-source-rebuild.test.ts` — 26 cases: 10 schema accept/
reject (full Honest Refusal Matrix for rebuild + cross-mode); 1
codec roundtrip; 4 resolver unit (all-existing, gaps recorded,
all-missing fail, operator order preserved); 6 orchestrator
integration including SQLite read-back asserting NO `resumeToken`
on rebuild seat and `continuityOutcome: "rebuilt"`; 3 honest-UX
literal contract; 2 real-filesystem smoke.
- `test/session-source-fork.test.ts` — two assertion strings updated
to match the new multi-mode error wording (`"v1 supports fork
only"` → `"v1 supports fork or rebuild"`; `"v1 supports native_id
only"` → `"v1 fork mode supports native_id only"`). Pure wording
propagation, no behavior change. All 33 fork cases still PASS.
- Daemon regression: 137 files, 2146/2146 PASS.
Tier 2 (real-runtime rebuild against a disposable Tart/OpenRig VM)
is the tester's call per the corrected SOP framing — disposable-VM
proof is default-allowed; skip only with concrete substrate reason.
Human gate applies only for protected/non-disposable hosts, host
`~/.claude` / `~/.codex` mutation, unexpected external spend/service
calls, destructive lifecycle, or leaving the disposable VM boundary.
Doc patches: `cli-reference.md` adds a new "Session source declaration
(`session_source`)" subsection covering BOTH modes (fork + rebuild)
with example YAML and validation rules. `architecture.md` adds a
one-paragraph reference covering both modes alongside the existing
cross-host notes.
Slice: openrig-work/missions/recursive-self-improvement-v2/primitive-hardening/slices/seat-continuity-and-handover/
Driver checkpoint: shared-docs rigs/openrig-velocity-claude/state/orch/velocity-seat-continuity-handover-driver-checkpoint-20260430.md
Guard accept: shared-docs rigs/openrig-velocity-claude/state/orch/velocity-seat-continuity-handover-guard-checkpoint-approve-20260430.md
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
cdce3a6864
commit
578bd5c57d
11 files changed
+803
-32
No files matched your search
@@ -149,6 +149,8 @@ Important route behaviors:
|
||||
- `rig send <session> "message" [--verify] [--force] [--host <id>]` — send to agent terminal; `--host` routes to a host declared in `~/.openrig/hosts.yaml` via single-hop ssh (CLI-side shell-out; daemon untouched). SSH success is NOT verify success — the remote rig's verify result is surfaced verbatim.
|
||||
- `rig capture <session> [--lines N] [--json] [--host <id>]` — capture agent pane content; `--host` same shape as `rig send --host`.
|
||||
- `rig broadcast --rig <name> "message"` — multi-agent broadcast
|
||||
|
||||
**Session source declaration:** Member YAML may carry `session_source` to control how a new managed seat derives its starting context. v1 supports two modes: `fork` (start from a prior native runtime conversation; captures and persists a NEW post-fork token, never the parent — see cli-reference.md "Session source declaration"); and `rebuild` (fresh-launch seeded with operator-declared artifacts in trust-precedence order; the seat's `continuityOutcome` is `rebuilt` and no `resumeToken` is persisted). Both modes flow through the existing rig spec and `rig expand` member-input shapes; no new top-level command was introduced for either.
|
||||
- `rig transcript <session> --tail N / --grep "pattern" [--json]` — transcript access
|
||||
- `rig config [get <key> / set <key> <value> / reset <key>] [--json]` — configuration surface
|
||||
- `rig preflight [--json]` — system readiness check (Node, tmux, writable dirs, port)
|
||||
|
||||
@@ -420,6 +420,52 @@ Usage: `rig expand <rig-id> <pod-fragment-path> [--json] [--rig-root <path>]`
|
||||
Notes:
|
||||
- Adds a pod fragment to a running rig.
|
||||
- `--rig-root` controls agent resolution.
|
||||
- Member YAML may carry `session_source` (see "Session source declaration" below) to start the new seat from a prior native conversation (`mode: fork`) or from operator-declared artifacts (`mode: rebuild`).
|
||||
|
||||
### Session source declaration (`session_source`)
|
||||
|
||||
Member YAML in a rig spec or `rig expand` payload may declare a launch-time `session_source` to control how the new managed seat derives its starting context. Two modes are supported in v1:
|
||||
|
||||
```yaml
|
||||
# Fork from a prior native runtime conversation. Captures and persists a NEW
|
||||
# post-fork token; the parent token is NEVER persisted onto the new seat.
|
||||
members:
|
||||
- id: reviewer-2
|
||||
runtime: claude-code # or "codex"; not valid on terminal
|
||||
session_source:
|
||||
mode: fork
|
||||
ref:
|
||||
kind: native_id # v1 fork mode supports "native_id" only
|
||||
value: "0b0165d7-cb4d-4650-90de-15c0a1ede9e6"
|
||||
```
|
||||
|
||||
```yaml
|
||||
# Rebuild from operator-declared artifacts (CULTURE, role doc, handover packet,
|
||||
# queue files, session logs). Fresh-launches the harness and seeds the running
|
||||
# TUI with the artifacts in the operator-declared trust-precedence order.
|
||||
# The seat's continuityOutcome is `rebuilt` (NEVER `fresh`/`resumed`/`forked`)
|
||||
# and NO `resumeToken` is persisted.
|
||||
members:
|
||||
- id: writer-2
|
||||
runtime: claude-code # or "codex"; not valid on terminal
|
||||
session_source:
|
||||
mode: rebuild
|
||||
ref:
|
||||
kind: artifact_set # v1 rebuild mode supports "artifact_set" only
|
||||
value: # ordered list, highest-trust first
|
||||
- /Users/wrandom/code/substrate/shared-docs/rigs/<rig>/CULTURE.md
|
||||
- /Users/wrandom/code/substrate/shared-docs/specs/agents/<role>.md
|
||||
- /path/to/handover-packet.md
|
||||
- /path/to/state/<pod>/<member>.queue.md
|
||||
- /path/to/state/<pod>/shared.session.log
|
||||
- /path/to/state/<pod>/<member>.session.log
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `terminal` runtime rejects `session_source` (no native fork primitive; no agent context to rebuild).
|
||||
- `mode: fork` requires `ref.kind: native_id` and a non-empty `ref.value` string. Other ref kinds (`artifact_path`, `name`, `last`) are reserved shapes for follow-up slices and are refused in v1 fork mode.
|
||||
- `mode: rebuild` requires `ref.kind: artifact_set` and a non-empty `ref.value` array of paths. Missing paths are recorded as gaps and the launch proceeds with what resolved; if NO declared paths resolve, the launch fails with a clear error.
|
||||
- The two modes are mutually exclusive on a given member; mixing is a schema error.
|
||||
|
||||
### `rig unclaim`
|
||||
|
||||
|
||||
@@ -63,6 +63,8 @@ export class RigSpecCodec {
|
||||
if (m.sessionSource.ref.value !== undefined) ref["value"] = m.sessionSource.ref.value;
|
||||
member["session_source"] = { mode: m.sessionSource.mode, ref };
|
||||
}
|
||||
// (rebuild ref.value is an array; the codec re-emits it as-is via the
|
||||
// same `ref.value` slot — the YAML serializer handles array emission.)
|
||||
return member;
|
||||
});
|
||||
|
||||
|
||||
@@ -1042,6 +1042,27 @@ export class PodRigInstantiator {
|
||||
codexConfigProfile: input.member.codexConfigProfile,
|
||||
};
|
||||
|
||||
// session_source dispatch: fork (native runtime fork) vs rebuild (artifact-
|
||||
// injected fresh launch). Mutually exclusive; only one is set on a member.
|
||||
let forkSourceOpt: { forkSource: { kind: "native_id" | "artifact_path" | "name" | "last"; value?: string } } | undefined;
|
||||
let rebuildArtifactsOpt: { rebuildArtifacts: import("./runtime-adapter.js").ResolvedStartupFile[] } | undefined;
|
||||
if (input.member.sessionSource?.mode === "fork") {
|
||||
const ref = input.member.sessionSource.ref;
|
||||
forkSourceOpt = {
|
||||
forkSource: {
|
||||
kind: ref.kind,
|
||||
...(ref.value !== undefined ? { value: ref.value } : {}),
|
||||
},
|
||||
};
|
||||
} else if (input.member.sessionSource?.mode === "rebuild") {
|
||||
const { resolveRebuildArtifacts } = await import("./session-source-rebuild-resolver.js");
|
||||
const resolved = resolveRebuildArtifacts(input.member.sessionSource);
|
||||
if (!resolved.ok) {
|
||||
return { status: "failed", error: resolved.error, sessionName: canonicalSessionName };
|
||||
}
|
||||
rebuildArtifactsOpt = { rebuildArtifacts: resolved.files };
|
||||
}
|
||||
|
||||
const startupResult = await this.deps.startupOrchestrator.startNode({
|
||||
rigId: input.rigId,
|
||||
nodeId: input.nodeId,
|
||||
@@ -1062,12 +1083,8 @@ export class PodRigInstantiator {
|
||||
}),
|
||||
],
|
||||
isRestore: false,
|
||||
...(input.member.sessionSource ? {
|
||||
forkSource: {
|
||||
kind: input.member.sessionSource.ref.kind,
|
||||
...(input.member.sessionSource.ref.value !== undefined ? { value: input.member.sessionSource.ref.value } : {}),
|
||||
},
|
||||
} : {}),
|
||||
...(forkSourceOpt ?? {}),
|
||||
...(rebuildArtifactsOpt ?? {}),
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -318,14 +318,23 @@ function validateSessionSource(raw: unknown, prefix: string, isTerminalRuntime:
|
||||
return errors;
|
||||
}
|
||||
if (isTerminalRuntime) {
|
||||
errors.push(`${prefix}: terminal runtime has no native fork primitive; remove session_source for terminal members`);
|
||||
errors.push(`${prefix}: terminal runtime has no native fork primitive and no agent context to rebuild; remove session_source for terminal members`);
|
||||
return errors;
|
||||
}
|
||||
const ss = raw as Record<string, unknown>;
|
||||
const mode = ss["mode"];
|
||||
if (mode !== "fork") {
|
||||
errors.push(`${prefix}.mode: v1 supports "fork" only (got ${JSON.stringify(mode)})`);
|
||||
if (mode === "fork") {
|
||||
return validateForkSessionSource(ss, prefix);
|
||||
}
|
||||
if (mode === "rebuild") {
|
||||
return validateRebuildSessionSource(ss, prefix);
|
||||
}
|
||||
errors.push(`${prefix}.mode: v1 supports "fork" or "rebuild" (got ${JSON.stringify(mode)})`);
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateForkSessionSource(ss: Record<string, unknown>, prefix: string): string[] {
|
||||
const errors: string[] = [];
|
||||
const ref = ss["ref"];
|
||||
if (ref === null || typeof ref !== "object") {
|
||||
errors.push(`${prefix}.ref: required object with "kind" and (when kind=native_id) "value"`);
|
||||
@@ -335,11 +344,13 @@ function validateSessionSource(raw: unknown, prefix: string, isTerminalRuntime:
|
||||
const kind = refRec["kind"];
|
||||
if (kind !== "native_id") {
|
||||
if (kind === "artifact_path") {
|
||||
errors.push(`${prefix}.ref.kind: "artifact_path" deferred to follow-up slice; v1 narrow MVP supports "native_id" only`);
|
||||
errors.push(`${prefix}.ref.kind: "artifact_path" deferred to follow-up slice; v1 fork mode supports "native_id" only`);
|
||||
} else if (kind === "name" || kind === "last") {
|
||||
errors.push(`${prefix}.ref.kind: "${kind}" is weaker than "native_id"; v1 scope supports "native_id" only`);
|
||||
errors.push(`${prefix}.ref.kind: "${kind}" is weaker than "native_id"; v1 fork mode supports "native_id" only`);
|
||||
} else if (kind === "artifact_set") {
|
||||
errors.push(`${prefix}.ref.kind: "artifact_set" belongs to mode "rebuild"; for fork mode use ref.kind: "native_id"`);
|
||||
} else {
|
||||
errors.push(`${prefix}.ref.kind: required; v1 supports "native_id" only (got ${JSON.stringify(kind)})`);
|
||||
errors.push(`${prefix}.ref.kind: required; v1 fork mode supports "native_id" only (got ${JSON.stringify(kind)})`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
@@ -350,6 +361,41 @@ function validateSessionSource(raw: unknown, prefix: string, isTerminalRuntime:
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateRebuildSessionSource(ss: Record<string, unknown>, prefix: string): string[] {
|
||||
const errors: string[] = [];
|
||||
const ref = ss["ref"];
|
||||
if (ref === null || typeof ref !== "object") {
|
||||
errors.push(`${prefix}.ref: required object with "kind: artifact_set" and "value: [paths...]" for rebuild mode`);
|
||||
return errors;
|
||||
}
|
||||
const refRec = ref as Record<string, unknown>;
|
||||
const kind = refRec["kind"];
|
||||
if (kind !== "artifact_set") {
|
||||
if (kind === "native_id" || kind === "artifact_path" || kind === "name" || kind === "last") {
|
||||
errors.push(`${prefix}.ref.kind: rebuild mode requires ref.kind: "artifact_set"; the named kinds (${JSON.stringify(kind)}) belong to mode: "fork"`);
|
||||
} else {
|
||||
errors.push(`${prefix}.ref.kind: required; v1 rebuild mode supports "artifact_set" only (got ${JSON.stringify(kind)})`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
const value = refRec["value"];
|
||||
if (!Array.isArray(value)) {
|
||||
errors.push(`${prefix}.ref.value: required non-empty array of artifact paths in trust-precedence order (highest-trust first: rig CULTURE, role doc, handover packet, queue file, pod shared session log, member session log)`);
|
||||
return errors;
|
||||
}
|
||||
if (value.length === 0) {
|
||||
errors.push(`${prefix}.ref.value: rebuild requires at least one artifact path; declare paths in trust-precedence order (highest-trust first)`);
|
||||
return errors;
|
||||
}
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const p = value[i];
|
||||
if (typeof p !== "string" || p.trim() === "") {
|
||||
errors.push(`${prefix}.ref.value[${i}]: each entry must be a non-empty string (file path)`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validatePodLocalEdge(edge: Record<string, unknown>, index: number, podPrefix: string, memberIds: Set<string>): string[] {
|
||||
const errors: string[] = [];
|
||||
const prefix = `${podPrefix}.edges[${index}]`;
|
||||
@@ -673,14 +719,28 @@ function normalizeSessionSource(raw: unknown): import("./types.js").SessionSourc
|
||||
if (raw === null || typeof raw !== "object") return undefined;
|
||||
const ss = raw as Record<string, unknown>;
|
||||
const mode = ss["mode"];
|
||||
if (mode !== "fork") return undefined;
|
||||
const ref = ss["ref"];
|
||||
if (ref === null || typeof ref !== "object") return undefined;
|
||||
const refRec = ref as Record<string, unknown>;
|
||||
const kind = refRec["kind"];
|
||||
if (kind !== "native_id" && kind !== "artifact_path" && kind !== "name" && kind !== "last") return undefined;
|
||||
const value = typeof refRec["value"] === "string" ? (refRec["value"] as string) : undefined;
|
||||
return { mode: "fork", ref: { kind, ...(value !== undefined ? { value } : {}) } };
|
||||
if (mode === "fork") {
|
||||
const kind = refRec["kind"];
|
||||
if (kind !== "native_id" && kind !== "artifact_path" && kind !== "name" && kind !== "last") return undefined;
|
||||
const value = typeof refRec["value"] === "string" ? (refRec["value"] as string) : undefined;
|
||||
return { mode: "fork", ref: { kind, ...(value !== undefined ? { value } : {}) } };
|
||||
}
|
||||
if (mode === "rebuild") {
|
||||
const kind = refRec["kind"];
|
||||
if (kind !== "artifact_set") return undefined;
|
||||
const value = refRec["value"];
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const paths: string[] = [];
|
||||
for (const p of value) {
|
||||
if (typeof p === "string" && p.trim() !== "") paths.push(p);
|
||||
}
|
||||
if (paths.length === 0) return undefined;
|
||||
return { mode: "rebuild", ref: { kind: "artifact_set", value: paths } };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizePod(raw: Record<string, unknown>): RigSpecPod {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { basename, dirname } from "node:path";
|
||||
import type { ResolvedStartupFile } from "./runtime-adapter.js";
|
||||
import type { SessionSourceRebuildSpec } from "./types.js";
|
||||
|
||||
/**
|
||||
* Result of resolving a `session_source.mode: rebuild` artifact set into the
|
||||
* `ResolvedStartupFile[]` shape that `adapter.deliverStartup` already
|
||||
* accepts. Records gaps (paths the operator declared but that don't exist
|
||||
* on disk at resolve time) without failing — the operator's other declared
|
||||
* artifacts may still carry enough context. The launch only fails if NONE
|
||||
* of the declared paths resolved.
|
||||
*/
|
||||
export interface RebuildArtifactsResult {
|
||||
ok: true;
|
||||
files: ResolvedStartupFile[];
|
||||
/** Paths from `ref.value` that did not resolve to an existing file. */
|
||||
gaps: string[];
|
||||
}
|
||||
|
||||
export type RebuildArtifactsOutcome =
|
||||
| RebuildArtifactsResult
|
||||
| { ok: false; error: string; gaps: string[] };
|
||||
|
||||
/**
|
||||
* Test/host injection seam: lets unit tests assert path resolution without
|
||||
* touching the real filesystem.
|
||||
*/
|
||||
export type ExistsFn = (path: string) => boolean;
|
||||
|
||||
/**
|
||||
* Resolve operator-declared rebuild artifacts into the orchestrator's
|
||||
* existing `ResolvedStartupFile[]` shape, preserving the operator's
|
||||
* trust-precedence ordering.
|
||||
*
|
||||
* Identity-honesty notes:
|
||||
* - This function does NOT execute, parse, or evaluate artifact contents.
|
||||
* It just records that a path exists and hands the orchestrator the
|
||||
* metadata it needs to deliver the bytes via the standard `deliverStartup`
|
||||
* seam (which itself only reads + paste-injects via tmux for the
|
||||
* `send_text` hint).
|
||||
* - The artifacts are tagged `appliesOn: ["fresh_start"]` because rebuild
|
||||
* IS a fresh launch from the runtime's perspective; the artifacts seed
|
||||
* it with operator context, but the runtime conversation itself is new.
|
||||
* `continuityOutcome: rebuilt` (set by the orchestrator) is the only
|
||||
* signal that distinguishes this from a vanilla fresh launch.
|
||||
*
|
||||
* @param spec - the rebuild spec from the member; `ref.value` is the
|
||||
* operator-declared list of artifact paths in trust-
|
||||
* precedence order (highest-trust first).
|
||||
* @param opts.exists - filesystem existence check; defaults to `existsSync`.
|
||||
* Tests pass a stub.
|
||||
*/
|
||||
export function resolveRebuildArtifacts(
|
||||
spec: SessionSourceRebuildSpec,
|
||||
opts: { exists?: ExistsFn } = {},
|
||||
): RebuildArtifactsOutcome {
|
||||
const exists = opts.exists ?? existsSync;
|
||||
const gaps: string[] = [];
|
||||
const files: ResolvedStartupFile[] = [];
|
||||
for (const path of spec.ref.value) {
|
||||
if (!exists(path)) {
|
||||
gaps.push(path);
|
||||
continue;
|
||||
}
|
||||
files.push({
|
||||
path: basename(path),
|
||||
absolutePath: path,
|
||||
ownerRoot: dirname(path),
|
||||
// `send_text` so the orchestrator's existing post-launch TUI delivery
|
||||
// path picks these up after the harness is ready. Operator-curated
|
||||
// context is meant to seed the running conversation, not to land as
|
||||
// filesystem-projected guidance/skill content.
|
||||
deliveryHint: "send_text",
|
||||
required: true,
|
||||
// `fresh_start` because rebuild IS a fresh-launch from the runtime's
|
||||
// perspective; the artifacts are the operator-declared seed context.
|
||||
appliesOn: ["fresh_start"],
|
||||
});
|
||||
}
|
||||
if (files.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `rebuild: none of the ${spec.ref.value.length} declared artifact path${spec.ref.value.length === 1 ? "" : "s"} resolved to an existing file. Verify the paths in session_source.ref.value (declared in trust-precedence order; highest-trust first).`,
|
||||
gaps,
|
||||
};
|
||||
}
|
||||
return { ok: true, files, gaps };
|
||||
}
|
||||
@@ -33,6 +33,15 @@ export interface StartupInput {
|
||||
* the new seat — the parent token is NEVER persisted.
|
||||
*/
|
||||
forkSource?: ForkSource;
|
||||
/**
|
||||
* Rebuild-mode artifact set (operator-declared via
|
||||
* `session_source.mode: rebuild`). When set, the orchestrator merges
|
||||
* these artifacts into the post-launch delivery path, fresh-launches
|
||||
* the harness with NO `resumeToken` and NO `forkSource`, and records
|
||||
* `continuityOutcome: "rebuilt"` on the seat. NEVER paired with
|
||||
* `resumeToken` or `forkSource` — rebuild is a distinct creation path.
|
||||
*/
|
||||
rebuildArtifacts?: ResolvedStartupFile[];
|
||||
/** Skip harness launch (legacy nodes that already resumed via old helpers). */
|
||||
skipHarnessLaunch?: boolean;
|
||||
/** Allow runtime adapter retry_fresh fallback when native resume data is stale. */
|
||||
@@ -42,7 +51,7 @@ export interface StartupInput {
|
||||
}
|
||||
|
||||
export type StartupResult =
|
||||
| { ok: true; startupStatus: "ready"; continuityOutcome: "resumed" | "fresh" | "forked" }
|
||||
| { ok: true; startupStatus: "ready"; continuityOutcome: "resumed" | "fresh" | "forked" | "rebuilt" }
|
||||
| { ok: false; startupStatus: "attention_required" | "failed"; errors: string[] };
|
||||
|
||||
interface StartupOrchestratorDeps {
|
||||
@@ -98,11 +107,13 @@ export class StartupOrchestrator {
|
||||
|
||||
async startNode(input: StartupInput): Promise<StartupResult> {
|
||||
const errors: string[] = [];
|
||||
let continuityOutcome: "resumed" | "fresh" | "forked" = input.resumeToken
|
||||
let continuityOutcome: "resumed" | "fresh" | "forked" | "rebuilt" = input.resumeToken
|
||||
? "resumed"
|
||||
: input.forkSource
|
||||
? "forked"
|
||||
: "fresh";
|
||||
: input.rebuildArtifacts && input.rebuildArtifacts.length > 0
|
||||
? "rebuilt"
|
||||
: "fresh";
|
||||
|
||||
// 1. Mark pending
|
||||
this.sessionRegistry.updateStartupStatus(input.sessionId, "pending");
|
||||
@@ -126,8 +137,16 @@ export class StartupOrchestrator {
|
||||
// 3. Partition startup files by concrete hint: pre-launch (filesystem) vs post-launch (TUI)
|
||||
// Note: new file-building paths (NS-T05+) emit only concrete hints. The auto fallback
|
||||
// is compatibility-only for pre-NS-T05 persisted startup contexts in node_startup_context.
|
||||
//
|
||||
// Rebuild-mode artifacts (when set) are merged in front of resolvedStartupFiles
|
||||
// so the operator's trust-precedence ordering is preserved when the post-launch
|
||||
// delivery loop walks the array. Rebuild artifacts are tagged
|
||||
// appliesOn: ["fresh_start"] by the resolver, which matches the rebuild context.
|
||||
const context = input.isRestore ? "restore" : "fresh_start";
|
||||
const applicableFiles = input.resolvedStartupFiles.filter((f) => f.appliesOn.includes(context));
|
||||
const sourceFiles = input.rebuildArtifacts && input.rebuildArtifacts.length > 0
|
||||
? [...input.rebuildArtifacts, ...input.resolvedStartupFiles]
|
||||
: input.resolvedStartupFiles;
|
||||
const applicableFiles = sourceFiles.filter((f) => f.appliesOn.includes(context));
|
||||
const preLaunchFiles: ResolvedStartupFile[] = [];
|
||||
let postLaunchFiles: ResolvedStartupFile[] = [];
|
||||
for (const f of applicableFiles) {
|
||||
|
||||
@@ -507,15 +507,25 @@ export interface ContinuityPolicySpec {
|
||||
}
|
||||
|
||||
/**
|
||||
* Member-level launch input for declaring that a new managed seat should be
|
||||
* started from a prior native runtime conversation source. v1 narrow MVP:
|
||||
* mode="fork" + ref.kind="native_id" only. The original seat is NOT claimed
|
||||
* to have continued; the new seat persists its own NEW post-fork token.
|
||||
* Member-level launch input for declaring how a new managed seat should
|
||||
* derive its starting context. Discriminated union over `mode`:
|
||||
*
|
||||
* Other ref.kind values ("artifact_path", "name", "last") are reserved
|
||||
* shapes — schema rejects them today with an honest "v1 scope" error.
|
||||
* - `fork` — start from a prior native runtime conversation source
|
||||
* (Claude `--fork-session` / Codex `fork`); persists a NEW post-fork
|
||||
* token; identity-honest (parent token is NEVER persisted onto the
|
||||
* new seat). v1 supports `ref.kind: "native_id"` only.
|
||||
*
|
||||
* - `rebuild` — fresh-launch a new seat seeded with operator-declared
|
||||
* artifacts (CULTURE, role doc, handover packet, queue files, session
|
||||
* logs). NO native-runtime resume or fork; NO `resumeToken` on the
|
||||
* resulting seat; `continuityOutcome` is `"rebuilt"` (NEVER `"fresh"`,
|
||||
* `"resumed"`, or `"forked"`). v1 supports `ref.kind: "artifact_set"`
|
||||
* only, with `ref.value` as a non-empty array of file paths in
|
||||
* operator-declared trust-precedence order.
|
||||
*/
|
||||
export interface SessionSourceSpec {
|
||||
export type SessionSourceSpec = SessionSourceForkSpec | SessionSourceRebuildSpec;
|
||||
|
||||
export interface SessionSourceForkSpec {
|
||||
mode: "fork";
|
||||
ref: {
|
||||
kind: "native_id" | "artifact_path" | "name" | "last";
|
||||
@@ -523,6 +533,14 @@ export interface SessionSourceSpec {
|
||||
};
|
||||
}
|
||||
|
||||
export interface SessionSourceRebuildSpec {
|
||||
mode: "rebuild";
|
||||
ref: {
|
||||
kind: "artifact_set";
|
||||
value: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface RigSpecPodMember {
|
||||
id: string;
|
||||
label?: string;
|
||||
|
||||
@@ -34,12 +34,20 @@ function normalizeExpansionPodFragment(raw: Record<string, unknown>): ExpansionP
|
||||
const ss = rawSessionSource as Record<string, unknown>;
|
||||
const mode = ss["mode"];
|
||||
const ref = ss["ref"];
|
||||
if (mode === "fork" && ref !== null && typeof ref === "object") {
|
||||
if (ref !== null && typeof ref === "object") {
|
||||
const refRec = ref as Record<string, unknown>;
|
||||
const kind = refRec["kind"];
|
||||
if (kind === "native_id" || kind === "artifact_path" || kind === "name" || kind === "last") {
|
||||
if (mode === "fork" && (kind === "native_id" || kind === "artifact_path" || kind === "name" || kind === "last")) {
|
||||
const value = typeof refRec["value"] === "string" ? (refRec["value"] as string) : undefined;
|
||||
sessionSource = { mode: "fork", ref: { kind, ...(value !== undefined ? { value } : {}) } };
|
||||
} else if (mode === "rebuild" && kind === "artifact_set" && Array.isArray(refRec["value"])) {
|
||||
const paths: string[] = [];
|
||||
for (const p of refRec["value"] as unknown[]) {
|
||||
if (typeof p === "string" && p.trim() !== "") paths.push(p);
|
||||
}
|
||||
if (paths.length > 0) {
|
||||
sessionSource = { mode: "rebuild", ref: { kind: "artifact_set", value: paths } };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ describe("session_source schema validation", () => {
|
||||
});
|
||||
const result = RigSpecSchema.validate(spec);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes('v1 supports "fork" only'))).toBe(true);
|
||||
expect(result.errors.some((e) => e.includes('v1 supports "fork" or "rebuild"'))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects ref.kind=artifact_path (deferred per dossier)", () => {
|
||||
@@ -159,7 +159,7 @@ describe("session_source schema validation", () => {
|
||||
});
|
||||
const result = RigSpecSchema.validate(spec);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes('v1 supports "native_id" only'))).toBe(true);
|
||||
expect(result.errors.some((e) => e.includes('v1 fork mode supports "native_id" only'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
|
||||
import { RigSpecSchema } from "../src/domain/rigspec-schema.js";
|
||||
import { RigSpecCodec } from "../src/domain/rigspec-codec.js";
|
||||
import { resolveRebuildArtifacts } from "../src/domain/session-source-rebuild-resolver.js";
|
||||
import { StartupOrchestrator, type StartupInput } from "../src/domain/startup-orchestrator.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import type { RuntimeAdapter, NodeBinding, ResolvedStartupFile } from "../src/domain/runtime-adapter.js";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import type { ProjectionPlan } from "../src/domain/projection-planner.js";
|
||||
import type { RigSpec, SessionSourceRebuildSpec } from "../src/domain/types.js";
|
||||
|
||||
// ============================================================================
|
||||
// Fixtures
|
||||
// ============================================================================
|
||||
|
||||
function baseValidSpec(): Record<string, unknown> {
|
||||
return {
|
||||
version: "0.2",
|
||||
name: "rebuild-test-rig",
|
||||
pods: [
|
||||
{
|
||||
id: "dev",
|
||||
label: "Dev pod",
|
||||
members: [
|
||||
{
|
||||
id: "writer",
|
||||
agent_ref: "local:agents/writer",
|
||||
profile: "default",
|
||||
runtime: "claude-code",
|
||||
cwd: ".",
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
};
|
||||
}
|
||||
|
||||
function withMember(spec: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown> {
|
||||
const next = JSON.parse(JSON.stringify(spec));
|
||||
const pods = next.pods as Array<Record<string, unknown>>;
|
||||
const members = pods[0]!.members as Array<Record<string, unknown>>;
|
||||
members[0] = { ...members[0], ...override };
|
||||
return next;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Schema validation — Honest Refusal Matrix (rebuild-mode rows)
|
||||
// ============================================================================
|
||||
|
||||
describe("session_source rebuild — schema validation", () => {
|
||||
it("accepts claude-code + rebuild + artifact_set with non-empty value array", () => {
|
||||
const spec = withMember(baseValidSpec(), {
|
||||
session_source: {
|
||||
mode: "rebuild",
|
||||
ref: { kind: "artifact_set", value: ["/tmp/CULTURE.md", "/tmp/role.md"] },
|
||||
},
|
||||
});
|
||||
const result = RigSpecSchema.validate(spec);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts codex + rebuild + artifact_set", () => {
|
||||
const spec = withMember(baseValidSpec(), {
|
||||
runtime: "codex",
|
||||
session_source: { mode: "rebuild", ref: { kind: "artifact_set", value: ["/x/y.md"] } },
|
||||
});
|
||||
const result = RigSpecSchema.validate(spec);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects terminal runtime with rebuild", () => {
|
||||
const spec = withMember(baseValidSpec(), {
|
||||
runtime: "terminal",
|
||||
agent_ref: "builtin:terminal",
|
||||
profile: "none",
|
||||
session_source: { mode: "rebuild", ref: { kind: "artifact_set", value: ["/x.md"] } },
|
||||
});
|
||||
const result = RigSpecSchema.validate(spec);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("terminal runtime has no native fork primitive and no agent context to rebuild"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects rebuild + ref.kind=native_id (kind belongs to fork mode)", () => {
|
||||
const spec = withMember(baseValidSpec(), {
|
||||
session_source: { mode: "rebuild", ref: { kind: "native_id", value: "abc" } },
|
||||
});
|
||||
const result = RigSpecSchema.validate(spec);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes('rebuild mode requires ref.kind: "artifact_set"'))).toBe(true);
|
||||
expect(result.errors.some((e) => e.includes('belong to mode: "fork"'))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects rebuild + ref.kind=artifact_path", () => {
|
||||
const spec = withMember(baseValidSpec(), {
|
||||
session_source: { mode: "rebuild", ref: { kind: "artifact_path", value: "/x" } },
|
||||
});
|
||||
const result = RigSpecSchema.validate(spec);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes('rebuild mode requires ref.kind: "artifact_set"'))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects rebuild + empty artifact_set value", () => {
|
||||
const spec = withMember(baseValidSpec(), {
|
||||
session_source: { mode: "rebuild", ref: { kind: "artifact_set", value: [] } },
|
||||
});
|
||||
const result = RigSpecSchema.validate(spec);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("rebuild requires at least one artifact path"))).toBe(true);
|
||||
// The error mentions trust-precedence ordering as guidance.
|
||||
expect(result.errors.some((e) => e.includes("trust-precedence"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects rebuild + value not an array", () => {
|
||||
const spec = withMember(baseValidSpec(), {
|
||||
session_source: { mode: "rebuild", ref: { kind: "artifact_set", value: "/just/one/path.md" } },
|
||||
});
|
||||
const result = RigSpecSchema.validate(spec);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("required non-empty array"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects rebuild + value array containing empty strings", () => {
|
||||
const spec = withMember(baseValidSpec(), {
|
||||
session_source: { mode: "rebuild", ref: { kind: "artifact_set", value: ["/x.md", " "] } },
|
||||
});
|
||||
const result = RigSpecSchema.validate(spec);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("each entry must be a non-empty string"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown ref.kind for rebuild", () => {
|
||||
const spec = withMember(baseValidSpec(), {
|
||||
session_source: { mode: "rebuild", ref: { kind: "magic", value: ["/x"] } },
|
||||
});
|
||||
const result = RigSpecSchema.validate(spec);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes('v1 rebuild mode supports "artifact_set" only'))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown mode (neither fork nor rebuild)", () => {
|
||||
const spec = withMember(baseValidSpec(), {
|
||||
session_source: { mode: "snapshot", ref: { kind: "native_id", value: "x" } },
|
||||
});
|
||||
const result = RigSpecSchema.validate(spec);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes('v1 supports "fork" or "rebuild"'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Codec roundtrip
|
||||
// ============================================================================
|
||||
|
||||
describe("session_source rebuild — codec roundtrip", () => {
|
||||
it("preserves rebuild + artifact_set + value array through serialize → parse → normalize", () => {
|
||||
const seed: RigSpec = {
|
||||
version: "0.2",
|
||||
name: "rebuild-test-rig",
|
||||
pods: [{
|
||||
id: "dev",
|
||||
label: "Dev pod",
|
||||
members: [{
|
||||
id: "writer",
|
||||
agentRef: "local:agents/writer",
|
||||
profile: "default",
|
||||
runtime: "claude-code",
|
||||
cwd: ".",
|
||||
sessionSource: { mode: "rebuild", ref: { kind: "artifact_set", value: ["/tmp/CULTURE.md", "/tmp/role.md", "/tmp/handover.md"] } },
|
||||
}],
|
||||
edges: [],
|
||||
}],
|
||||
edges: [],
|
||||
};
|
||||
const yaml = RigSpecCodec.serialize(seed);
|
||||
expect(yaml).toContain("session_source:");
|
||||
expect(yaml).toContain("mode: rebuild");
|
||||
expect(yaml).toContain("kind: artifact_set");
|
||||
expect(yaml).toContain("/tmp/CULTURE.md");
|
||||
expect(yaml).toContain("/tmp/role.md");
|
||||
expect(yaml).toContain("/tmp/handover.md");
|
||||
|
||||
const parsed = RigSpecCodec.parse(yaml);
|
||||
const validation = RigSpecSchema.validate(parsed);
|
||||
expect(validation.valid).toBe(true);
|
||||
|
||||
const normalized = RigSpecSchema.normalize(parsed as Record<string, unknown>) as RigSpec;
|
||||
const member = normalized.pods[0]!.members[0]!;
|
||||
expect(member.sessionSource).toEqual(seed.pods[0]!.members[0]!.sessionSource);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Rebuild artifact resolver
|
||||
// ============================================================================
|
||||
|
||||
describe("resolveRebuildArtifacts", () => {
|
||||
function makeSpec(paths: string[]): SessionSourceRebuildSpec {
|
||||
return { mode: "rebuild", ref: { kind: "artifact_set", value: paths } };
|
||||
}
|
||||
|
||||
it("resolves all-existing paths into ResolvedStartupFile[] preserving operator order", () => {
|
||||
const exists = (p: string) => p === "/x/CULTURE.md" || p === "/y/role.md" || p === "/z/handover.md";
|
||||
const result = resolveRebuildArtifacts(makeSpec(["/x/CULTURE.md", "/y/role.md", "/z/handover.md"]), { exists });
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.files.map((f) => f.absolutePath)).toEqual(["/x/CULTURE.md", "/y/role.md", "/z/handover.md"]);
|
||||
expect(result.gaps).toEqual([]);
|
||||
// Identity-honesty: deliveryHint is `send_text` (operator-curated context for the running TUI).
|
||||
expect(result.files.every((f) => f.deliveryHint === "send_text")).toBe(true);
|
||||
// appliesOn: ["fresh_start"] — rebuild IS a fresh launch from the runtime's perspective.
|
||||
expect(result.files.every((f) => f.appliesOn.includes("fresh_start"))).toBe(true);
|
||||
// Required so the orchestrator surfaces missing-resource failures honestly.
|
||||
expect(result.files.every((f) => f.required === true)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("records missing paths as gaps without failing if some files exist", () => {
|
||||
const exists = (p: string) => p === "/exists.md";
|
||||
const result = resolveRebuildArtifacts(makeSpec(["/exists.md", "/missing.md", "/also-missing.md"]), { exists });
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.files.map((f) => f.absolutePath)).toEqual(["/exists.md"]);
|
||||
expect(result.gaps).toEqual(["/missing.md", "/also-missing.md"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("fails the launch with a clear error when ALL declared paths are missing", () => {
|
||||
const exists = () => false;
|
||||
const result = resolveRebuildArtifacts(makeSpec(["/missing-1.md", "/missing-2.md"]), { exists });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain("none of the 2 declared artifact paths resolved");
|
||||
expect(result.error).toContain("trust-precedence order");
|
||||
expect(result.gaps).toEqual(["/missing-1.md", "/missing-2.md"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves operator-declared ordering (does NOT impose alphabetical or any other sort)", () => {
|
||||
const exists = () => true;
|
||||
const order = ["/zzz-low-trust.md", "/aaa-mid-trust.md", "/mmm-highest-trust.md"];
|
||||
const result = resolveRebuildArtifacts(makeSpec(order), { exists });
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.files.map((f) => f.absolutePath)).toEqual(order);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Startup orchestrator — rebuild integration
|
||||
// ============================================================================
|
||||
|
||||
function mockOrchTmux(): TmuxAdapter {
|
||||
return {
|
||||
sendText: vi.fn(async () => ({ ok: true as const })),
|
||||
hasSession: vi.fn(async () => true),
|
||||
createSession: vi.fn(async () => ({ ok: true as const })),
|
||||
killSession: vi.fn(async () => ({ ok: true as const })),
|
||||
listSessions: vi.fn(async () => []),
|
||||
listWindows: vi.fn(async () => []),
|
||||
listPanes: vi.fn(async () => []),
|
||||
sendKeys: vi.fn(async () => ({ ok: true as const })),
|
||||
} as unknown as TmuxAdapter;
|
||||
}
|
||||
|
||||
function makeStubAdapter(): RuntimeAdapter {
|
||||
return {
|
||||
runtime: "claude-code",
|
||||
listInstalled: vi.fn(async () => []),
|
||||
project: vi.fn(async () => ({ projected: [], skipped: [], failed: [] })),
|
||||
deliverStartup: vi.fn(async () => ({ delivered: 0, failed: [] })),
|
||||
checkReady: vi.fn(async () => ({ ready: true })),
|
||||
// Fresh-launch shape: returns ok with NO resumeToken (rebuild seats have
|
||||
// no native runtime conversation to resume from).
|
||||
launchHarness: vi.fn(async () => ({ ok: true })),
|
||||
};
|
||||
}
|
||||
|
||||
function emptyPlan(): ProjectionPlan {
|
||||
return { runtime: "claude-code", cwd: ".", entries: [], startup: { files: [], actions: [] }, conflicts: [], noOps: [], diagnostics: [] };
|
||||
}
|
||||
|
||||
describe("StartupOrchestrator rebuild integration", () => {
|
||||
let db: Database.Database;
|
||||
let sessionRegistry: SessionRegistry;
|
||||
let eventBus: EventBus;
|
||||
let rigRepo: RigRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createFullTestDb();
|
||||
sessionRegistry = new SessionRegistry(db);
|
||||
eventBus = new EventBus(db);
|
||||
rigRepo = new RigRepository(db);
|
||||
});
|
||||
afterEach(() => { db.close(); });
|
||||
|
||||
function seed(): { rigId: string; nodeId: string; sessionId: string } {
|
||||
const rig = rigRepo.createRig("rebuild-rig");
|
||||
const node = rigRepo.addNode(rig.id, "writer", { runtime: "claude-code" });
|
||||
const session = sessionRegistry.registerSession(node.id, "r01-writer");
|
||||
sessionRegistry.updateStatus(session.id, "running");
|
||||
return { rigId: rig.id, nodeId: node.id, sessionId: session.id };
|
||||
}
|
||||
|
||||
function makeInput(s: { rigId: string; nodeId: string; sessionId: string }, overrides?: Partial<StartupInput>): StartupInput {
|
||||
return {
|
||||
rigId: s.rigId,
|
||||
nodeId: s.nodeId,
|
||||
sessionId: s.sessionId,
|
||||
binding: { id: "b1", nodeId: s.nodeId, tmuxSession: "r01-writer", tmuxWindow: null, tmuxPane: null, cmuxWorkspace: null, cmuxSurface: null, updatedAt: "", cwd: "." },
|
||||
adapter: makeStubAdapter(),
|
||||
plan: emptyPlan(),
|
||||
resolvedStartupFiles: [],
|
||||
startupActions: [],
|
||||
isRestore: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createOrch(): StartupOrchestrator {
|
||||
return new StartupOrchestrator({
|
||||
db, sessionRegistry, eventBus,
|
||||
tmuxAdapter: mockOrchTmux(),
|
||||
sleep: async () => {},
|
||||
});
|
||||
}
|
||||
|
||||
function makeRebuildArtifacts(): ResolvedStartupFile[] {
|
||||
return [
|
||||
{ path: "CULTURE.md", absolutePath: "/x/CULTURE.md", ownerRoot: "/x", deliveryHint: "send_text", required: true, appliesOn: ["fresh_start"] },
|
||||
{ path: "role.md", absolutePath: "/y/role.md", ownerRoot: "/y", deliveryHint: "send_text", required: true, appliesOn: ["fresh_start"] },
|
||||
{ path: "handover.md", absolutePath: "/z/handover.md", ownerRoot: "/z", deliveryHint: "send_text", required: true, appliesOn: ["fresh_start"] },
|
||||
];
|
||||
}
|
||||
|
||||
it("sets continuityOutcome=rebuilt when rebuildArtifacts is provided and launch succeeds", async () => {
|
||||
const s = seed();
|
||||
const orch = createOrch();
|
||||
const result = await orch.startNode(makeInput(s, { rebuildArtifacts: makeRebuildArtifacts() }));
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
startupStatus: "ready",
|
||||
continuityOutcome: "rebuilt",
|
||||
});
|
||||
});
|
||||
|
||||
it("calls adapter.launchHarness with NO resumeToken AND NO forkSource on rebuild", async () => {
|
||||
const s = seed();
|
||||
const adapter = makeStubAdapter();
|
||||
const launchSpy = adapter.launchHarness as ReturnType<typeof vi.fn>;
|
||||
const orch = createOrch();
|
||||
await orch.startNode(makeInput(s, { adapter, rebuildArtifacts: makeRebuildArtifacts() }));
|
||||
|
||||
expect(launchSpy).toHaveBeenCalledTimes(1);
|
||||
const opts = launchSpy.mock.calls[0]![1];
|
||||
expect(opts.resumeToken).toBeUndefined();
|
||||
expect(opts.forkSource).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rebuild seat persists with NO resumeToken (identity-honesty: rebuild is artifact-injected, not native-resumed)", async () => {
|
||||
const s = seed();
|
||||
const orch = createOrch();
|
||||
await orch.startNode(makeInput(s, { rebuildArtifacts: makeRebuildArtifacts() }));
|
||||
|
||||
const row = db.prepare("SELECT resume_token FROM sessions WHERE id = ?").get(s.sessionId) as { resume_token: string | null };
|
||||
expect(row.resume_token).toBeNull();
|
||||
});
|
||||
|
||||
it("hands rebuild artifacts to adapter.deliverStartup (post-launch delivery via send_text)", async () => {
|
||||
const s = seed();
|
||||
const adapter = makeStubAdapter();
|
||||
const deliverSpy = adapter.deliverStartup as ReturnType<typeof vi.fn>;
|
||||
const orch = createOrch();
|
||||
const artifacts = makeRebuildArtifacts();
|
||||
|
||||
await orch.startNode(makeInput(s, { adapter, rebuildArtifacts: artifacts }));
|
||||
|
||||
// deliverStartup is called twice: once pre-launch (filesystem files; empty
|
||||
// for rebuild-only) and once post-launch (TUI files; the rebuild artifacts).
|
||||
expect(deliverSpy.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||
// Post-launch call must include the rebuild artifacts in operator order.
|
||||
const allDeliveredFiles = deliverSpy.mock.calls.flatMap((call) => call[0] as ResolvedStartupFile[]);
|
||||
const deliveredAbsPaths = allDeliveredFiles.map((f) => f.absolutePath);
|
||||
for (const artifact of artifacts) {
|
||||
expect(deliveredAbsPaths).toContain(artifact.absolutePath);
|
||||
}
|
||||
// Operator order preserved: CULTURE before role before handover.
|
||||
const cultureIdx = deliveredAbsPaths.indexOf("/x/CULTURE.md");
|
||||
const roleIdx = deliveredAbsPaths.indexOf("/y/role.md");
|
||||
const handoverIdx = deliveredAbsPaths.indexOf("/z/handover.md");
|
||||
expect(cultureIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(cultureIdx).toBeLessThan(roleIdx);
|
||||
expect(roleIdx).toBeLessThan(handoverIdx);
|
||||
});
|
||||
|
||||
it("fresh path (no rebuildArtifacts, no resumeToken, no forkSource) is unchanged: continuityOutcome=fresh", async () => {
|
||||
const s = seed();
|
||||
const orch = createOrch();
|
||||
const result = await orch.startNode(makeInput(s));
|
||||
expect(result).toEqual({ ok: true, startupStatus: "ready", continuityOutcome: "fresh" });
|
||||
});
|
||||
|
||||
it("fork path (forkSource set) is unchanged by rebuild plumbing: continuityOutcome=forked", async () => {
|
||||
const s = seed();
|
||||
const adapter = makeStubAdapter();
|
||||
(adapter.launchHarness as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
resumeToken: "NEW-FORK-TOKEN",
|
||||
resumeType: "claude_id",
|
||||
});
|
||||
const orch = createOrch();
|
||||
const result = await orch.startNode(makeInput(s, {
|
||||
adapter,
|
||||
forkSource: { kind: "native_id", value: "PARENT-FORK-TOKEN" },
|
||||
}));
|
||||
expect(result).toEqual({ ok: true, startupStatus: "ready", continuityOutcome: "forked" });
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Honest UX literal contract — continuityOutcome includes "rebuilt"; no
|
||||
// `restored|resumed|snapshot|forked` slips into the rebuild dossier wording.
|
||||
// ============================================================================
|
||||
|
||||
describe("identity-honesty literal contract — rebuild", () => {
|
||||
it('continuityOutcome union accepts "rebuilt" alongside other outcomes', () => {
|
||||
const r: { ok: true; startupStatus: "ready"; continuityOutcome: "rebuilt" } = {
|
||||
ok: true, startupStatus: "ready", continuityOutcome: "rebuilt",
|
||||
};
|
||||
expect(r.continuityOutcome).toBe("rebuilt");
|
||||
});
|
||||
|
||||
it("rebuild mode literal is 'rebuild' (NOT 'fresh', 'resumed', 'forked', 'restored', or 'snapshot')", () => {
|
||||
const spec: SessionSourceRebuildSpec = { mode: "rebuild", ref: { kind: "artifact_set", value: ["/x"] } };
|
||||
expect(spec.mode).toBe("rebuild");
|
||||
expect(["fresh", "resumed", "forked", "restored", "snapshot"]).not.toContain(spec.mode as string);
|
||||
});
|
||||
|
||||
it("resolver-produced ResolvedStartupFile entries do NOT carry any 'restored'/'resumed'/'snapshot'/'forked' tags", () => {
|
||||
const exists = () => true;
|
||||
const result = resolveRebuildArtifacts(
|
||||
{ mode: "rebuild", ref: { kind: "artifact_set", value: ["/x.md"] } },
|
||||
{ exists },
|
||||
);
|
||||
if (!result.ok) throw new Error("expected ok");
|
||||
const serialized = JSON.stringify(result.files);
|
||||
expect(serialized).not.toMatch(/restored/i);
|
||||
expect(serialized).not.toMatch(/resumed/i);
|
||||
expect(serialized).not.toMatch(/snapshot/i);
|
||||
expect(serialized).not.toMatch(/forked/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Real-filesystem smoke: resolver default `exists` works (light integration)
|
||||
// ============================================================================
|
||||
|
||||
describe("rebuild resolver real-filesystem smoke (default existsSync)", () => {
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "openrig-rebuild-resolver-"));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("resolves real files written under a temp dir", () => {
|
||||
const a = join(dir, "CULTURE.md");
|
||||
const b = join(dir, "role.md");
|
||||
writeFileSync(a, "culture body");
|
||||
writeFileSync(b, "role body");
|
||||
|
||||
const result = resolveRebuildArtifacts({
|
||||
mode: "rebuild",
|
||||
ref: { kind: "artifact_set", value: [a, b] },
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.files.map((f) => f.absolutePath)).toEqual([a, b]);
|
||||
expect(result.gaps).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns gaps for missing files alongside resolved ones", () => {
|
||||
const real = join(dir, "real.md");
|
||||
writeFileSync(real, "x");
|
||||
const ghost = join(dir, "ghost.md"); // never written
|
||||
|
||||
const result = resolveRebuildArtifacts({
|
||||
mode: "rebuild",
|
||||
ref: { kind: "artifact_set", value: [real, ghost] },
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.files.map((f) => f.absolutePath)).toEqual([real]);
|
||||
expect(result.gaps).toEqual([ghost]);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user