Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Managed workspace continuation admission v1

## 范围

本切片只证明一个主要不变量:

> 对 `managed-coding-v1` 会话,continuation claim 必须同时绑定不可变 RuntimeEvent replay 边界和当前已接受的 Gitoxide workspace head;任一边界变化都不得启动 provider。

它不改变 provider replay 的 high-water 语义,也不创建新的 workspace baseline。

## Owner 与事实源

- RuntimeEvent replay 边界由 SQLite continuation authority 从不可变事件前缀签发。
- accepted workspace 边界由 workspace version authority 从 epoch、head、version 和 storage-root binding 重建。
- Runtime Host 只负责重新观测 Gitoxide `refs/maka/accepted`,并证明它与 SQLite head 一致。
- `ContinuationClaimV2` 使用 domain-separated digest 绑定上述两个边界;target Run 使用 `continuation_source_v3`,同时保留独立的 replay manifest digest。

调用者不能提交裸 workspace digest,也不能用 v1 claim 替代 v2 claim。

## 原子性边界

SQLite 在一个事务中校验:

1. immutable RuntimeEvent prefix;
2. storage-root binding;
3. workspace epoch/head/version;
4. claim target identity;
5. continuation claim 的唯一性。

claim 与 continuation-start 是两个 durable 边界。若进程停在两者之间,recovery 只能在重新观测到完全相同的 workspace boundary 后写入 repair start;它不会调 provider。

## 失败状态与回滚

- workspace head、accepted ref、receipt 或 storage root 不一致:park / fail closed。
- workspace-bound authority 缺失:park;禁止回退 v1。
- v2 claim 已提交、start 未提交:创建或核对 claim 中的 target Run,写 repair start,再写可审计失败终态。
- normal start 已提交但 provider 状态未知:保持 indeterminate,不自动重放 provider。
- claim 事务内崩溃:SQLite 回滚,不留下半 claim。

## 平台能力

| 平台 | 本切片承诺 |
| --- | --- |
| Linux | SQLite claim/start 原子性;Gitoxide accepted-ref 重验;process-crash 收敛 |
| macOS | 与 Linux 相同;路径以 `realpath` 后身份为准 |
| Windows | SQLite claim/start 原子性;Gitoxide accepted-ref 重验;不依赖 POSIX inode 语义 |

断电持久性沿用 SQLite/Gitoxide owner 各自已经声明的平台合同,本切片不扩大承诺。

## 交付状态

这是 stacked Draft。Desktop/CLI 自动 continuation 仍受现有 feature gate 控制;在 production-shaped Host kill/reopen 证据进入三平台 recovery inventory 前,不转 Ready。
20 changes: 20 additions & 0 deletions packages/core/src/runtime-event-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,26 @@ export interface RuntimeWorkspaceBoundContinuationAuthorityStore extends Runtime
readWorkspaceBoundContinuationClaimByBoundary(
boundaryDigest: RuntimeBoundaryDigest,
): Promise<ContinuationClaimV2 | undefined>;
readWorkspaceBoundContinuationClaimStateByBoundary(
boundaryDigest: RuntimeBoundaryDigest,
): Promise<ContinuationClaimStateV2 | undefined>;
listWorkspaceBoundContinuationClaimsForRecovery(
sessionId: string,
): Promise<ContinuationClaimStateV2[]>;
commitWorkspaceBoundContinuationStart(input: {
claim: ContinuationClaimV2;
event: RuntimeEvent;
}): Promise<{ created: boolean; runtimeEventSeq: number }>;
commitWorkspaceBoundContinuationRepairStart(input: {
claim: ContinuationClaimV2;
event: RuntimeEvent;
}): Promise<{ created: boolean; runtimeEventSeq: number }>;
}

export interface ContinuationClaimStateV2 {
claim: ContinuationClaimV2;
startEventId?: string;
startKind?: 'runtime_admission' | 'claim_repair';
}

export interface RuntimeWorkspaceVersionAuthorityStore extends RuntimeEventStore {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
issueGitoxideHelperReleaseArtifactClaimInternal,
} from '../server/gitoxide-helper-artifact-authority-internal.js';
import {
inspectGitoxideManagedContinuationBoundary,
openGitoxideManagedMutationSession,
recoverGitoxideManagedMutationBeforeRunClosureInternal,
} from '../server/gitoxide-managed-mutation-session.js';
Expand Down Expand Up @@ -100,8 +101,12 @@ test('opens one durable Gitoxide baseline and exactly reuses it for the session'

const first = await openGitoxideManagedMutationSession(input);
const reopened = await openGitoxideManagedMutationSession(input);
const initialContinuationBoundary = await inspectGitoxideManagedContinuationBoundary(input);

assert.deepEqual(reopened.head, first.head);
assert.equal(initialContinuationBoundary?.commitOid, first.head.commitOid);
assert.equal(initialContinuationBoundary?.treeOid, first.head.treeOid);
assert.equal(initialContinuationBoundary?.revision, first.head.revision);
assert.equal(first.head.revision, 1);
assert.notEqual(first.head.commitOid, git(sourceRoot, ['rev-parse', 'HEAD']));
assert.equal(first.head.treeOid, git(sourceRoot, ['rev-parse', 'HEAD^{tree}']));
Expand Down Expand Up @@ -161,6 +166,10 @@ test('opens one durable Gitoxide baseline and exactly reuses it for the session'
'after\n',
);
assert.equal(await readFile(join(sourceRoot, 'notes.txt'), 'utf8'), 'baseline\n');
const successorContinuationBoundary = await inspectGitoxideManagedContinuationBoundary(input);
assert.equal(successorContinuationBoundary?.commitOid, afterChange.head.commitOid);
assert.equal(successorContinuationBoundary?.treeOid, afterChange.head.treeOid);
assert.equal(successorContinuationBoundary?.revision, 3);

const noChange = await executeManagedWrite({
stores,
Expand Down
14 changes: 14 additions & 0 deletions packages/runtime-host/src/server/execution-composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ import {
import { resolvePackagedGitoxideHelperInternal } from './packaged-gitoxide-helper-internal.js';
import type { GitoxideHelperInvocationCapability } from './gitoxide-helper-artifact-authority-internal.js';
import {
inspectGitoxideManagedContinuationBoundary,
openGitoxideManagedMutationSession,
recoverGitoxideManagedMutationBeforeRunClosureInternal,
} from './gitoxide-managed-mutation-session.js';
Expand Down Expand Up @@ -1004,6 +1005,19 @@ export async function createExecutionRuntimeHostComposition(
resourcesLive || graphLive || graphWake.hasLiveSessionState(sessionId) || descendantLive
);
},
readManagedWorkspaceBoundary: async (sessionId) => {
const header = await stores.sessionStore.readHeaderSnapshot(sessionId);
if (header.toolProfile !== 'managed-coding-v1') return undefined;
const runtime = requireGitoxideManagedMutationRuntime(gitoxideManagedMutationRuntime);
return inspectGitoxideManagedContinuationBoundary({
storageRootLease: context.owner.lease,
sourceRoot: header.cwd,
sessionId,
invocationOwnerToken: runtime.invocationOwnerToken,
helperCapability: runtime.helperCapability,
settlementAuthority: requireExecutionStoresWorkspaceMutationAuthorityInternal(stores),
});
},
}),
runBackendActivation: (operation) => runtimePolicyActivation.runBackendActivation(operation),
messageAuthority: runtimeAuthority,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { dirname, join } from 'node:path';
import { workspaceMutationPolicyHashV1 } from '@maka/core/workspace-version-authority';
import { GITOXIDE_MANAGED_MUTATION_TRANSFORM_PROFILE_DIGEST } from '@maka/runtime/managed-mutation-transform';
import type { ToolRuntimeInput } from '@maka/runtime/tool-runtime';
import type { ManagedWorkspaceContinuationBoundaryV1 } from '@maka/core/runtime-boundary';
import type { WorkspaceHeadRecordV1 } from '@maka/core/workspace-version-authority';
import type { ExecutionStoresWorkspaceMutationAuthorityInternal } from '@maka/storage/execution-stores-workspace-authority-internal';
import { runWithStorageRootLease, type StorageRootLease } from '@maka/storage/root-authority';
Expand Down Expand Up @@ -133,6 +134,72 @@ export async function recoverGitoxideManagedMutationBeforeRunClosureInternal(inp
}
}

/**
* Re-observes an already accepted managed workspace without creating a
* baseline. This is the only Host seam allowed to issue the workspace half of
* a continuation boundary.
*/
export async function inspectGitoxideManagedContinuationBoundary(input: {
readonly storageRootLease: StorageRootLease<'interactive', 'write'>;
readonly sourceRoot: string;
readonly sessionId: string;
readonly invocationOwnerToken: object;
readonly helperCapability: GitoxideHelperInvocationCapability;
readonly settlementAuthority: ExecutionStoresWorkspaceMutationAuthorityInternal;
readonly abortSignal?: AbortSignal;
}): Promise<ManagedWorkspaceContinuationBoundaryV1 | undefined> {
input.abortSignal?.throwIfAborted();
const [storageRoot, sourceRoot, helper] = await Promise.all([
runWithStorageRootLease(input.storageRootLease, 'interactive', 'write', async (root) => root),
realpath(input.sourceRoot),
verifyGitoxideHelperArtifactForInvocationInternal(
input.invocationOwnerToken,
input.helperCapability,
),
]);
const materializationProfileDigest = sha256(
`maka-gitoxide-materialization-v1\0${helper.artifactSha256}\0`,
);
const workspacePolicyHash = workspaceMutationPolicyHashV1(
materializationProfileDigest,
GITOXIDE_MANAGED_MUTATION_TRANSFORM_PROFILE_DIGEST,
);
const identity = managedMutationIdentity(sourceRoot, input.sessionId);
const boundary = await input.settlementAuthority.readContinuationBoundary(
identity.workspaceId,
identity.workspaceEpochId,
GITOXIDE_MANAGED_MUTATION_TRANSFORM_PROFILE_DIGEST,
);
if (!boundary) return undefined;
if (
boundary.repositoryId !== identity.repositoryId ||
boundary.workspaceId !== identity.workspaceId ||
boundary.workspaceEpochId !== identity.workspaceEpochId ||
boundary.workspaceInstanceId !== identity.workspaceInstanceId
) {
throw new Error('Gitoxide managed continuation boundary conflicts with session identity');
}
const repositoryPath = gitoxideManagedRepositoryPathInternal(storageRoot, identity);
const receipt = await readBaselineReceipt(join(dirname(repositoryPath), 'baseline-receipt.json'));
if (
!receipt ||
receipt.repositoryId !== boundary.repositoryId ||
receipt.workspaceId !== boundary.workspaceId ||
receipt.workspaceEpochId !== boundary.workspaceEpochId ||
receipt.workspaceInstanceId !== boundary.workspaceInstanceId ||
receipt.helperArtifactSha256 !== helper.artifactSha256 ||
boundary.materializationProfileDigest !== materializationProfileDigest ||
boundary.executionProfileDigest !==
GITOXIDE_MANAGED_MUTATION_TRANSFORM_PROFILE_DIGEST ||
boundary.policyHash !== workspacePolicyHash
) {
throw new Error('Gitoxide managed continuation baseline receipt is unavailable');
}
await verifyAcceptedRef(input, repositoryPath, boundary.commitOid, boundary.treeOid);
input.abortSignal?.throwIfAborted();
return boundary;
}

/**
* Opens one explicit managed-coding session. The source observation is frozen
* before import, Gitoxide owns the immutable repository, SQLite owns accepted
Expand Down
13 changes: 11 additions & 2 deletions packages/runtime-host/src/server/root-turn-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { createHash, randomUUID } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util';
import type { BackendStopMode } from '@maka/core/backend-types';
import type { AgentRunHeader, RootExecutionDescriptor } from '@maka/core/agent-run';
import { digestWorkspaceBoundContinuationBoundary } from '@maka/core/runtime-boundary';
import {
INLINE_REFERENCE_MAX_COUNT,
messageContentsEqual,
Expand Down Expand Up @@ -2665,12 +2666,18 @@ function requirePlannedContinuation(plan: SafeBoundaryContinuationPlan): Runtime
function continuationExecutionDescriptor(
continuation: RuntimeContinuation,
): Extract<RootExecutionDescriptor, { kind: 'safe_boundary_continuation' }> {
const boundaryDigest = continuation.boundary?.manifestDigest;
if (!continuation.claimId || !boundaryDigest || !continuation.providerReplayDigest) {
const replayManifestDigest = continuation.boundary?.manifestDigest;
if (!continuation.claimId || !replayManifestDigest || !continuation.providerReplayDigest) {
throw new RuntimeMessageAuthorityInvariantError(
'Authoritative continuation plan omitted its durable replay proof',
);
}
const boundaryDigest = continuation.workspaceBoundary
? digestWorkspaceBoundContinuationBoundary(
continuation.boundary!,
continuation.workspaceBoundary,
)
: replayManifestDigest;
return {
kind: 'safe_boundary_continuation',
sourceInvocationId: continuation.sourceInvocationId,
Expand All @@ -2679,6 +2686,7 @@ function continuationExecutionDescriptor(
sourceRuntimeEventHighWater: continuation.sourceRuntimeEventHighWater,
claimId: continuation.claimId,
boundaryDigest,
...(continuation.workspaceBoundary ? { replayManifestDigest } : {}),
providerReplayDigest: continuation.providerReplayDigest,
safetyDigest: continuationSafetyDigest(continuation),
targetInvocationId: continuation.invocationId,
Expand All @@ -2695,6 +2703,7 @@ export function continuationSafetyDigest(continuation: RuntimeContinuation): `sh
snapshot.workspaceCheckpoint
? [snapshot.workspaceCheckpoint.ref, snapshot.workspaceCheckpoint.runtimeEventHighWater]
: null,
...(snapshot.workspaceBoundary ? [snapshot.workspaceBoundary] : []),
]);
return `sha256:${createHash('sha256').update(body, 'utf8').digest('hex')}`;
}
Expand Down
104 changes: 104 additions & 0 deletions packages/runtime/src/__tests__/runtime-continuation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
createRuntimeBoundaryCursor,
runtimePrefixSegment,
type ImmutableRuntimePrefixV1,
type ManagedWorkspaceContinuationBoundaryV1,
} from '@maka/core/runtime-boundary';
import type { RuntimeEvent } from '@maka/core/runtime-event';
import type { AgentRunHeader } from '@maka/core/agent-run';
Expand Down Expand Up @@ -337,6 +338,89 @@ test('RuntimeContinuationPlanner reads the durable source boundary and allocates
});
});

test('RuntimeContinuationPlanner binds managed continuation admission to the observed accepted head', async () => {
const sourcePrefix = immutablePrefix([
event({
id: 'source-user',
role: 'user',
author: 'user',
content: { kind: 'text', text: 'continue managed work' },
}),
event({
id: 'source-terminal',
role: 'system',
author: 'system',
status: 'failed',
actions: { endInvocation: true },
}),
]);
const workspaceBoundary = managedWorkspaceBoundary();
let readWorkspaceClaim = false;
const ids = ['invocation-2', 'run-2', 'turn-2', 'claim-2'];
const planner = new RuntimeContinuationPlanner({
readSourceRun: async () => runHeader('run-1'),
readImmutableRuntimePrefix: async () => sourcePrefix,
readWorkspaceBoundContinuationClaimStateByBoundary: async () => {
readWorkspaceClaim = true;
return undefined;
},
newId: () => ids.shift() ?? 'unexpected-id',
});

const plan = await planner.plan({
sessionId: 'session-1',
sourceRunId: 'run-1',
currentCwd: '/workspace/repo',
sourceWorkspaceIdentity: 'workspace-1',
currentWorkspaceIdentity: 'workspace-1',
backgroundOperationsSettled: true,
availableToolNames: ['Write', 'Edit'],
workspaceBoundary,
});

assert.equal(plan.disposition, 'continue');
assert.equal(readWorkspaceClaim, true);
assert.deepEqual(plan.continuation?.workspaceBoundary, workspaceBoundary);
assert.deepEqual(plan.continuation?.safetySnapshot.workspaceBoundary, workspaceBoundary);
});

test('RuntimeContinuationPlanner never downgrades a managed boundary to the v1 claim authority', async () => {
let readV1Claim = false;
const planner = new RuntimeContinuationPlanner({
readSourceRun: async () => runHeader('run-1'),
readImmutableRuntimePrefix: async () =>
immutablePrefix([
event({
id: 'source-terminal',
role: 'system',
author: 'system',
status: 'failed',
actions: { endInvocation: true },
}),
]),
readContinuationClaimStateByBoundary: async () => {
readV1Claim = true;
return undefined;
},
newId: () => 'unused',
});

const plan = await planner.plan({
sessionId: 'session-1',
sourceRunId: 'run-1',
currentCwd: '/workspace/repo',
sourceWorkspaceIdentity: 'workspace-1',
currentWorkspaceIdentity: 'workspace-1',
backgroundOperationsSettled: true,
availableToolNames: [],
workspaceBoundary: managedWorkspaceBoundary(),
});

assert.equal(plan.disposition, 'park');
assert.deepEqual(plan.rejectionReasons, ['continuation_authority_unavailable']);
assert.equal(readV1Claim, false);
});

test('RuntimeRunner rejects a continuation envelope whose high-water is behind its replay context', async () => {
const runner = new RuntimeRunner({
flow: {
Expand Down Expand Up @@ -1149,6 +1233,26 @@ function runHeader(runId: string, overrides: Partial<AgentRunHeader> = {}): Agen
};
}

function managedWorkspaceBoundary(): ManagedWorkspaceContinuationBoundaryV1 {
return {
protocol: 'managed_workspace_continuation_boundary_v1',
storageRootId: 'a'.repeat(64),
repositoryId: `repository_${'1'.repeat(32)}`,
workspaceId: `workspace_${'2'.repeat(32)}`,
workspaceEpochId: `epoch_${'3'.repeat(32)}`,
workspaceInstanceId: `instance_${'4'.repeat(32)}`,
workspaceVersionId: `version_${'5'.repeat(32)}`,
acceptedEventId: 'accepted-event-1',
revision: 2,
objectFormat: 'sha1',
commitOid: 'b'.repeat(40),
treeOid: 'c'.repeat(40),
materializationProfileDigest: `sha256:${'d'.repeat(64)}`,
policyHash: `sha256:${'e'.repeat(64)}`,
executionProfileDigest: `sha256:${'f'.repeat(64)}`,
};
}

function event(overrides: Partial<RuntimeEvent>): RuntimeEvent {
return {
id: 'event',
Expand Down
Loading
Loading