diff --git a/README.en.md b/README.en.md
index f789ebe..c86082b 100644
--- a/README.en.md
+++ b/README.en.md
@@ -17,7 +17,7 @@
-
+
@@ -353,7 +353,7 @@ it should behave, and why previous decisions were made.
## Installation
-**Status**: mancode Continuity v0.3.16. Claude Code, Cursor, Codex in the ChatGPT
+**Status**: mancode Continuity v0.3.18. Claude Code, Cursor, Codex in the ChatGPT
desktop app and CLI, GitHub Copilot, and ZCode adapters are included.
Requires Node.js 20 or newer. macOS, Linux, Windows CMD, PowerShell, and Git Bash
@@ -452,7 +452,7 @@ mancode version
Simplified output:
```text
-mancode v0.3.16
+mancode v0.3.18
Project: my-app
Runtime: ready
diff --git a/README.md b/README.md
index f98f441..d818221 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@
-
+
@@ -307,7 +307,7 @@ src/components/
## 安装
-**状态**:mancode Continuity v0.3.16。Claude Code、Cursor、ChatGPT 桌面端中的
+**状态**:mancode Continuity v0.3.18。Claude Code、Cursor、ChatGPT 桌面端中的
Codex、Codex CLI、GitHub Copilot 和 ZCode adapter 均已接入。
需要 Node.js 20 或更高版本。原生支持 macOS、Linux、Windows CMD、
@@ -404,7 +404,7 @@ mancode version
以下是简化输出示例:
```text
-mancode v0.3.16
+mancode v0.3.18
Project: my-app
Runtime: ready
diff --git a/package-lock.json b/package-lock.json
index 8ec0e32..61744e8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "mancode",
- "version": "0.3.16",
+ "version": "0.3.18",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mancode",
- "version": "0.3.16",
+ "version": "0.3.18",
"license": "AGPL-3.0-only",
"dependencies": {
"commander": "^12.1.0",
diff --git a/package.json b/package.json
index a5caea0..b2e9989 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "mancode",
- "version": "0.3.16",
+ "version": "0.3.18",
"description": "AI coding agent workflow harness with mancode Continuity for cross-conversation tasks, decisions, verification, and team coordination.",
"type": "module",
"license": "AGPL-3.0-only",
diff --git a/src/runtime/task-operation.ts b/src/runtime/task-operation.ts
index 0395dd4..ebe1102 100644
--- a/src/runtime/task-operation.ts
+++ b/src/runtime/task-operation.ts
@@ -1,5 +1,8 @@
+import { execFile as execFileCallback } from 'node:child_process';
import { lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
+import { promisify } from 'node:util';
+import { taskAggregateDigest } from '../context/aggregate.js';
import { assertCompatibilityGate } from '../context/compatibility.js';
import { type Ulid, assertUlid, createUlid } from '../context/ids.js';
import { scanLegacyAuthority } from '../context/layout.js';
@@ -79,6 +82,8 @@ import {
import { type SessionStateV1, readSession } from './session.js';
import { assertTaskHeadFenceMatchesAggregate } from './task-head-fence.js';
+const execFile = promisify(execFileCallback);
+
export interface OpenV3TaskOperationInput {
projectRoot: string;
taskRef: TaskRef;
@@ -216,11 +221,22 @@ export async function openV3TaskOperation(
throw new Error('MANCODE_TASK_HEAD_FENCE_MISSING');
}
if (input.allowTaskHeadFenceMismatch !== true) {
- assertTaskHeadFenceMatchesAggregate(
- coordination.taskHeadFence,
- task.aggregate,
- codeHead,
- );
+ try {
+ assertTaskHeadFenceMatchesAggregate(
+ coordination.taskHeadFence,
+ task.aggregate,
+ codeHead,
+ );
+ } catch (error) {
+ await assertOwnerCodeHeadAdvance({
+ projectRoot,
+ task,
+ fence: coordination.taskHeadFence,
+ codeHead,
+ actorId: session.actorId,
+ originalError: error,
+ });
+ }
}
}
return {
@@ -251,6 +267,35 @@ export async function openV3TaskOperation(
}
}
+async function assertOwnerCodeHeadAdvance(input: {
+ projectRoot: string;
+ task: StoredTaskSnapshot;
+ fence: NonNullable;
+ codeHead: string;
+ actorId: Ulid;
+ originalError: unknown;
+}): Promise {
+ const { task, fence } = input;
+ if (
+ task.aggregate === null ||
+ task.metadata.ownerActorId !== input.actorId ||
+ fence.taskRevision !== task.metadata.revision ||
+ fence.ownershipEpoch !== task.metadata.ownershipEpoch ||
+ fence.aggregateDigest !== taskAggregateDigest(task.aggregate)
+ ) {
+ throw input.originalError;
+ }
+ try {
+ await execFile(
+ 'git',
+ ['merge-base', '--is-ancestor', fence.codeRef.head, input.codeHead],
+ { cwd: input.projectRoot, windowsHide: true },
+ );
+ } catch {
+ throw input.originalError;
+ }
+}
+
/**
* A child may continue to be read for diagnosis after its parent changes, but
* no child mutation may race ahead of the parent snapshot it was created from.
diff --git a/src/team/git-ref-bundle.ts b/src/team/git-ref-bundle.ts
index d3aed09..e90ebac 100644
--- a/src/team/git-ref-bundle.ts
+++ b/src/team/git-ref-bundle.ts
@@ -1,10 +1,15 @@
import { execFile as execFileCallback } from 'node:child_process';
-import { lstat, mkdir, writeFile } from 'node:fs/promises';
+import { lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { promisify } from 'node:util';
import { digestCanonicalJson } from '../context/canonical.js';
import type { StoredTaskSnapshot } from '../context/store.js';
-import { formatTaskRef } from '../context/task-ref.js';
+import {
+ type TaskRef,
+ formatTaskRef,
+ parseTaskRefValue,
+ sameTaskRef,
+} from '../context/task-ref.js';
import {
type GitRefJsonValue,
type GitRefTaskBundleArtifactKind,
@@ -139,6 +144,81 @@ export async function quarantineGitRefTaskBundle(
return target;
}
+export async function readQuarantinedGitRefTaskBundle(
+ projectRoot: string,
+ remoteRevision: number,
+ taskRef: TaskRef,
+ expected: {
+ taskRevision: number;
+ aggregateDigest: string;
+ ownershipEpoch: number;
+ codeRefHead: string;
+ },
+): Promise {
+ if (!Number.isSafeInteger(remoteRevision) || remoteRevision < 1) {
+ throw new Error('MANCODE_TRANSPORT_REVISION_INVALID');
+ }
+ const parsedTaskRef = parseTaskRefValue(taskRef);
+ const directory = path.join(
+ path.resolve(projectRoot),
+ '.mancode',
+ 'local',
+ 'quarantine',
+ 'git-ref',
+ parsedTaskRef.taskId,
+ String(remoteRevision),
+ );
+ let entries: string[];
+ try {
+ await assertFixedDirectory(projectRoot, [
+ '.mancode',
+ 'local',
+ 'quarantine',
+ 'git-ref',
+ parsedTaskRef.taskId,
+ String(remoteRevision),
+ ]);
+ entries = await readdir(directory);
+ } catch (error) {
+ if (isNotFound(error)) return null;
+ throw error;
+ }
+ const matches: GitRefTaskBundleV1[] = [];
+ for (const entry of entries.sort()) {
+ if (!/^[a-f0-9]{64}\.json$/.test(entry)) continue;
+ const target = path.join(directory, entry);
+ const before = await lstat(target);
+ if (!before.isFile() || before.isSymbolicLink()) {
+ throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE');
+ }
+ const bundle = parseGitRefTaskBundle(
+ JSON.parse(await readFile(target, 'utf8')),
+ );
+ const after = await lstat(target);
+ if (
+ !after.isFile() ||
+ after.isSymbolicLink() ||
+ before.dev !== after.dev ||
+ before.ino !== after.ino
+ ) {
+ throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE');
+ }
+ if (
+ sameTaskRef(bundle.taskRef, parsedTaskRef) &&
+ bundle.taskRevision === expected.taskRevision &&
+ bundle.aggregateDigest === expected.aggregateDigest &&
+ bundle.ownershipEpoch === expected.ownershipEpoch &&
+ bundle.codeRef.head === expected.codeRefHead
+ ) {
+ matches.push(bundle);
+ }
+ }
+ if (matches.length > 1) {
+ throw new Error('MANCODE_TRANSPORT_CACHE_CORRUPT');
+ }
+ return matches[0] ?? null;
+}
+
function artifact(
kind: GitRefTaskBundleArtifactKind,
relativePath: string,
@@ -185,6 +265,20 @@ async function ensureFixedDirectory(
}
}
+async function assertFixedDirectory(
+ projectRoot: string,
+ segments: string[],
+): Promise {
+ let current = path.resolve(projectRoot);
+ for (const segment of segments) {
+ current = path.join(current, segment);
+ const entry = await lstat(current);
+ if (!entry.isDirectory() || entry.isSymbolicLink()) {
+ throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE');
+ }
+ }
+}
+
function isAlreadyExists(error: unknown): error is NodeJS.ErrnoException {
return (
typeof error === 'object' &&
@@ -193,3 +287,12 @@ function isAlreadyExists(error: unknown): error is NodeJS.ErrnoException {
(error as NodeJS.ErrnoException).code === 'EEXIST'
);
}
+
+function isNotFound(error: unknown): error is NodeJS.ErrnoException {
+ return (
+ typeof error === 'object' &&
+ error !== null &&
+ 'code' in error &&
+ (error as NodeJS.ErrnoException).code === 'ENOENT'
+ );
+}
diff --git a/src/team/git-ref-coordination.ts b/src/team/git-ref-coordination.ts
index 3c1e789..f59e5a1 100644
--- a/src/team/git-ref-coordination.ts
+++ b/src/team/git-ref-coordination.ts
@@ -60,6 +60,7 @@ export type PrepareGitRefCoordinationMutationInput = BaseGitRefMutation &
(
| {
kind: 'ownership_fence';
+ expectedPredecessorBundleDigest: string | null;
taskBundle: GitRefTaskBundleV1;
}
| {
@@ -172,7 +173,11 @@ export function prepareGitRefCoordinationMutation(
const context = openMutationContext(manifest, input);
switch (input.kind) {
case 'ownership_fence':
- return prepareOwnershipFence(context, input.taskBundle);
+ return prepareOwnershipFence(
+ context,
+ input.taskBundle,
+ input.expectedPredecessorBundleDigest,
+ );
case 'claim_acquire':
return prepareClaimAcquire(
context,
@@ -324,6 +329,7 @@ function openMutationContext(
function prepareOwnershipFence(
context: MutationContext,
taskBundle: GitRefTaskBundleV1,
+ expectedPredecessorBundleDigest: string | null,
): PreparedGitRefCoordinationMutation {
const metadata = metadataFromBundle(taskBundle);
assertRemoteTaskEligible(metadata);
@@ -331,6 +337,12 @@ function prepareOwnershipFence(
if (metadata.ownerActorId === null) {
throw new Error('MANCODE_TASK_OWNER_REQUIRED');
}
+ if (
+ (context.taskBundle?.bundleDigest ?? null) !==
+ expectedPredecessorBundleDigest
+ ) {
+ throw new Error('MANCODE_TASK_BUNDLE_DIVERGED');
+ }
if (context.fence === null) {
if (
metadata.ownerActorId !== context.input.actorId ||
@@ -351,17 +363,64 @@ function prepareOwnershipFence(
throw new Error('MANCODE_TASK_REVISION_CONFLICT');
}
if (taskBundle.taskRevision === context.fence.taskRevision) {
+ if (taskBundle.aggregateDigest !== context.fence.aggregateDigest) {
+ throw new Error('MANCODE_SPLIT_BRAIN');
+ }
if (
- taskBundle.aggregateDigest !== context.fence.aggregateDigest ||
- taskBundle.codeRef.head !== context.taskBundle?.codeRef.head
+ taskBundle.codeRef.branch !== context.taskBundle?.codeRef.branch ||
+ taskBundle.codeRef.head === context.taskBundle?.codeRef.head
) {
- throw new Error('MANCODE_SPLIT_BRAIN');
+ throw new Error('MANCODE_REMOTE_FENCE_NO_CHANGE');
}
- throw new Error('MANCODE_REMOTE_FENCE_NO_CHANGE');
+ return prepared(
+ context,
+ buildFence(context, taskBundle, metadata.ownerActorId),
+ refreshOwnerClaimsForCodeRef(context, taskBundle, metadata),
+ context.handoffs,
+ taskBundle,
+ );
}
}
+ const codeHeadChanged =
+ context.taskBundle !== null &&
+ (taskBundle.codeRef.branch !== context.taskBundle.codeRef.branch ||
+ taskBundle.codeRef.head !== context.taskBundle.codeRef.head);
const fence = buildFence(context, taskBundle, metadata.ownerActorId);
- return prepared(context, fence, context.claims, context.handoffs, taskBundle);
+ return prepared(
+ context,
+ fence,
+ codeHeadChanged
+ ? refreshOwnerClaimsForCodeRef(context, taskBundle, metadata)
+ : context.claims,
+ context.handoffs,
+ taskBundle,
+ );
+}
+
+function refreshOwnerClaimsForCodeRef(
+ context: MutationContext,
+ taskBundle: GitRefTaskBundleV1,
+ metadata: WorkflowMetadataV3,
+): ClaimV1[] {
+ return context.claims.map((claim) => {
+ if (
+ claim.state !== 'active' ||
+ claim.ownerActorId !== context.input.actorId ||
+ Date.parse(claim.expiresAt) <= context.now.getTime() ||
+ claim.ownershipEpochAtAcquire !== taskBundle.ownershipEpoch ||
+ claim.implementationScopeDigest !== metadata.implementationScope.digest
+ ) {
+ return claim;
+ }
+ const next = bindClaim(context, {
+ ...claim,
+ revision: claim.revision + 1,
+ lastValidatedTaskRevision: taskBundle.taskRevision,
+ lastValidatedCodeRef: taskBundle.codeRef,
+ });
+ assertClaimTransition(claim, next);
+ return next;
+ });
}
function prepareClaimAcquire(
@@ -890,6 +949,7 @@ function prepared(
claims: claims.map(parseClaim).sort(compareClaims),
handoffs: handoffs.map(parseHandoff).sort(compareHandoffs),
taskBundle,
+ expectedTaskBundleDigest: context.taskBundle?.bundleDigest ?? null,
forwardRepair: null,
};
}
diff --git a/src/team/git-ref-handoff-repair.ts b/src/team/git-ref-handoff-repair.ts
index 442463a..849b501 100644
--- a/src/team/git-ref-handoff-repair.ts
+++ b/src/team/git-ref-handoff-repair.ts
@@ -181,6 +181,7 @@ export async function recoverGitRefHandoffRepair(
bundle: journal.prepared.targetBundle,
predecessorBundle: journal.prepared.predecessorBundle,
pendingMetadata: journal.pendingMetadata,
+ taskLockHeld: true,
operationId: createUlid(),
});
journal = await transitionJournal(
diff --git a/src/team/git-ref-materialization.ts b/src/team/git-ref-materialization.ts
index 619c998..14cadac 100644
--- a/src/team/git-ref-materialization.ts
+++ b/src/team/git-ref-materialization.ts
@@ -31,6 +31,12 @@ import {
readTaskHeadFence,
replaceTaskHeadFence,
} from '../runtime/task-head-store.js';
+import { taskEntityKey } from '../runtime/task-operation.js';
+import { readQuarantinedGitRefTaskBundle } from './git-ref-bundle.js';
+import {
+ readGitRefTaskRemoteBase,
+ recordGitRefTaskRemoteBase,
+} from './git-ref-task-base.js';
import {
type GitRefOwnershipFenceV1,
type GitRefTaskBundleArtifactV1,
@@ -62,6 +68,8 @@ export interface MaterializeGitRefTaskBundleInput {
bundle: GitRefTaskBundleV1;
predecessorBundle?: GitRefTaskBundleV1 | null;
pendingMetadata?: WorkflowMetadataV3 | null;
+ /** The caller already holds the canonical task lock. */
+ taskLockHeld?: boolean;
operationId?: Ulid;
now?: Date;
}
@@ -114,12 +122,14 @@ export async function materializeGitRefTaskBundle(
const store = resolveCoordinationEntityHomeStore(
runtime.entityHomeStoreContext,
);
- const locks = await acquireEntityLocks(
- store,
- operationId,
- [`remote_task:${bundle.taskRef.taskId}`],
- { now },
- );
+ const locks = input.taskLockHeld
+ ? []
+ : await acquireEntityLocks(
+ store,
+ operationId,
+ [taskEntityKey(bundle.taskRef)],
+ { now },
+ );
try {
await recoverTaskMaterializationsWhileLocked(
projectRoot,
@@ -127,16 +137,53 @@ export async function materializeGitRefTaskBundle(
);
const current = await readLocalTaskOrNull(projectRoot, bundle);
const currentFence = await readTaskHeadFence(store, bundle.taskRef);
+ const recordedBase = await readGitRefTaskRemoteBase(
+ projectRoot,
+ bundle.taskRef,
+ );
+ const quarantinedBase =
+ recordedBase === null &&
+ currentFence !== null &&
+ currentFence.remoteRevision !== null
+ ? await readQuarantinedGitRefTaskBundle(
+ projectRoot,
+ currentFence.remoteRevision,
+ bundle.taskRef,
+ {
+ taskRevision: currentFence.taskRevision,
+ aggregateDigest: currentFence.aggregateDigest,
+ ownershipEpoch: currentFence.ownershipEpoch,
+ codeRefHead: currentFence.codeRef.head,
+ },
+ )
+ : null;
+ const effectivePredecessor =
+ recordedBase?.bundle ?? quarantinedBase ?? predecessor;
const status = classifyMaterialization(
current,
bundle,
- predecessor,
+ effectivePredecessor,
pendingMetadata,
);
if (status === 'created' && currentFence !== null) {
throw new Error('MANCODE_SPLIT_BRAIN');
}
- const materializedPredecessor = status === 'created' ? null : predecessor;
+ const materializedPredecessor =
+ status === 'created' ? null : effectivePredecessor;
+ if (
+ status === 'unchanged' &&
+ currentFence !== null &&
+ taskHeadFenceMatchesTarget({
+ currentFence,
+ runtime,
+ remoteRevision,
+ ownershipFence,
+ bundle,
+ })
+ ) {
+ await recordGitRefTaskRemoteBase(projectRoot, remoteRevision, bundle);
+ return result(status, bundle, null, currentFence);
+ }
const targetFence = buildTargetFence({
runtime,
remoteRevision,
@@ -147,7 +194,13 @@ export async function materializeGitRefTaskBundle(
now,
});
if (status === 'unchanged') {
- await writeTargetFence(store, currentFence, targetFence, predecessor);
+ await writeTargetFence(
+ store,
+ currentFence,
+ targetFence,
+ materializedPredecessor,
+ );
+ await recordGitRefTaskRemoteBase(projectRoot, remoteRevision, bundle);
return result(status, bundle, null, targetFence);
}
const timestamp = now.toISOString();
@@ -168,6 +221,7 @@ export async function materializeGitRefTaskBundle(
journal = { ...journal, state: 'applying', updatedAt: timestamp };
await replaceJournal(projectRoot, journal);
await applyJournal(projectRoot, journal);
+ await recordGitRefTaskRemoteBase(projectRoot, remoteRevision, bundle);
journal = {
...journal,
state: 'committed',
@@ -202,7 +256,7 @@ export async function recoverGitRefTaskMaterializations(
if (journal.state === 'committed') continue;
const recoveryOperationId = createUlid();
const locks = await acquireEntityLocks(store, recoveryOperationId, [
- `remote_task:${journal.targetBundle.taskRef.taskId}`,
+ taskEntityKey(journal.targetBundle.taskRef),
]);
try {
repaired += await recoverTaskMaterializationsWhileLocked(
@@ -231,6 +285,11 @@ async function recoverTaskMaterializationsWhileLocked(
continue;
}
await applyJournal(projectRoot, journal);
+ await recordGitRefTaskRemoteBase(
+ projectRoot,
+ journal.remoteRevision,
+ journal.targetBundle,
+ );
await replaceJournal(projectRoot, {
...journal,
state: 'committed',
@@ -397,6 +456,26 @@ function buildTargetFence(input: {
});
}
+function taskHeadFenceMatchesTarget(input: {
+ currentFence: TaskHeadFenceV1;
+ runtime: Awaited>;
+ remoteRevision: number;
+ ownershipFence: GitRefOwnershipFenceV1;
+ bundle: GitRefTaskBundleV1;
+}): boolean {
+ return (
+ input.currentFence.workspaceId === input.runtime.workspaceId &&
+ sameTaskRef(input.currentFence.taskRef, input.bundle.taskRef) &&
+ input.currentFence.taskRevision === input.bundle.taskRevision &&
+ input.currentFence.aggregateDigest === input.bundle.aggregateDigest &&
+ input.currentFence.ownershipEpoch === input.bundle.ownershipEpoch &&
+ input.currentFence.codeRef.head === input.bundle.codeRef.head &&
+ input.currentFence.checkoutId === input.runtime.checkoutId &&
+ input.currentFence.remoteRevision === input.remoteRevision &&
+ input.currentFence.lastOperationId === input.ownershipFence.lastOperationId
+ );
+}
+
async function writeTargetFence(
store: ReturnType,
current: TaskHeadFenceV1 | null,
diff --git a/src/team/git-ref-operation.ts b/src/team/git-ref-operation.ts
index 0739f2c..4971416 100644
--- a/src/team/git-ref-operation.ts
+++ b/src/team/git-ref-operation.ts
@@ -38,6 +38,10 @@ import {
type GitRefOwnershipForwardRepairV1,
prepareGitRefCoordinationMutation,
} from './git-ref-coordination.js';
+import {
+ readGitRefTaskRemoteBase,
+ recordGitRefTaskRemoteBase,
+} from './git-ref-task-base.js';
import {
type GitRefTaskBundleV1,
type GitRefTeamManifestSnapshot,
@@ -211,6 +215,10 @@ export async function syncGitRefTask(
);
const snapshot = await transport.pull();
const bundle = await bundleFromContext(context, now);
+ const remoteBase = await readGitRefTaskRemoteBase(
+ context.projectRoot,
+ taskRef,
+ );
const remoteTaskPublished =
snapshot.manifest?.ownershipFences.some((fence) =>
sameTaskRef(fence.taskRef, taskRef),
@@ -231,6 +239,7 @@ export async function syncGitRefTask(
transport,
snapshot,
bundle,
+ remoteBase?.bundle ?? null,
operationId,
now,
);
@@ -240,6 +249,12 @@ export async function syncGitRefTask(
context.project.config,
refreshed,
);
+ const refreshedManifest = requireRemoteManifest(refreshed);
+ await recordGitRefTaskRemoteBase(
+ context.projectRoot,
+ refreshedManifest.revision,
+ requireRemoteBundle(refreshedManifest, taskRef),
+ );
return { bundle, ...result };
} finally {
await context.release();
@@ -275,6 +290,10 @@ export async function acquireGitRefClaim(
);
let snapshot = await transport.pull();
const bundle = await bundleFromContext(context, now);
+ const remoteBase = await readGitRefTaskRemoteBase(
+ context.projectRoot,
+ taskRef,
+ );
if (!remoteBundleMatches(snapshot, bundle)) {
await assertCleanGitWorktree(context.projectRoot);
const bootstrapOperationId = createUlid(now.getTime());
@@ -283,6 +302,7 @@ export async function acquireGitRefClaim(
transport,
snapshot,
bundle,
+ remoteBase?.bundle ?? null,
bootstrapOperationId,
now,
);
@@ -290,6 +310,11 @@ export async function acquireGitRefClaim(
}
const manifest = requireRemoteManifest(snapshot);
const fence = requireRemoteFence(manifest, taskRef);
+ await recordGitRefTaskRemoteBase(
+ context.projectRoot,
+ manifest.revision,
+ requireRemoteBundle(manifest, taskRef),
+ );
const remote = context.project.config.transport.remote;
if (remote === null) throw new Error('MANCODE_TRANSPORT_UNAVAILABLE');
const remoteIdentityHash = await resolveGitRefRemoteIdentityHash(
@@ -326,6 +351,11 @@ export async function acquireGitRefClaim(
context.project.config,
refreshed,
);
+ await recordGitRefTaskRemoteBase(
+ context.projectRoot,
+ requireRemoteManifest(refreshed).revision,
+ requireRemoteBundle(requireRemoteManifest(refreshed), taskRef),
+ );
const claim = requireRemoteManifest(refreshed).claims.find(
(candidate) => candidate.claimId === claimId,
);
@@ -928,6 +958,7 @@ async function synchronizeBundle(
transport: GitRefTeamManifestStore,
snapshot: GitRefTeamManifestSnapshot,
bundle: GitRefTaskBundleV1,
+ remoteBase: GitRefTaskBundleV1 | null,
operationId: Ulid,
now: Date,
): Promise> {
@@ -943,6 +974,22 @@ async function synchronizeBundle(
changed: false,
};
}
+ if (fence !== undefined && fence.ownerActorId !== context.session.actorId) {
+ throw new Error('MANCODE_TASK_OWNER_REQUIRED');
+ }
+ const remoteBundle =
+ fence === undefined ? null : requireRemoteBundle(manifest, context.taskRef);
+ if (
+ (remoteBundle === null) !== (remoteBase === null) ||
+ (remoteBundle !== null &&
+ (remoteBase === null ||
+ remoteBundle.bundleDigest !== remoteBase.bundleDigest))
+ ) {
+ throw new Error('MANCODE_TASK_BUNDLE_DIVERGED');
+ }
+ if (remoteBundle !== null) {
+ await assertCodeRefFastForward(context.projectRoot, remoteBundle, bundle);
+ }
const mutation = prepareGitRefCoordinationMutation(manifest, {
kind: 'ownership_fence',
operationId,
@@ -950,6 +997,7 @@ async function synchronizeBundle(
taskRef: context.taskRef,
expectedRemoteRevision: manifest.revision,
expectedOwnershipEpoch: fence?.ownershipEpoch ?? 0,
+ expectedPredecessorBundleDigest: remoteBundle?.bundleDigest ?? null,
taskBundle: bundle,
now,
});
@@ -1036,6 +1084,26 @@ function remoteBundleMatches(
);
}
+async function assertCodeRefFastForward(
+ projectRoot: string,
+ previous: GitRefTaskBundleV1,
+ next: GitRefTaskBundleV1,
+): Promise {
+ if (previous.codeRef.branch !== next.codeRef.branch) {
+ throw new Error('MANCODE_TASK_BUNDLE_DIVERGED');
+ }
+ if (previous.codeRef.head === next.codeRef.head) return;
+ try {
+ await execFile(
+ 'git',
+ ['merge-base', '--is-ancestor', previous.codeRef.head, next.codeRef.head],
+ { cwd: projectRoot, windowsHide: true },
+ );
+ } catch {
+ throw new Error('MANCODE_TASK_BUNDLE_DIVERGED');
+ }
+}
+
function requireRemoteManifest(snapshot: GitRefTeamManifestSnapshot) {
if (snapshot.manifest === null) {
throw new Error('MANCODE_TRANSPORT_ACTOR_NOT_JOINED');
diff --git a/src/team/git-ref-task-base.ts b/src/team/git-ref-task-base.ts
new file mode 100644
index 0000000..88a18e1
--- /dev/null
+++ b/src/team/git-ref-task-base.ts
@@ -0,0 +1,198 @@
+import { lstat, mkdir, readFile, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { type Ulid, assertUlid } from '../context/ids.js';
+import {
+ type TaskRef,
+ parseTaskRefValue,
+ sameTaskRef,
+} from '../context/task-ref.js';
+import { assertKnownKeys, assertRecord } from '../context/validation.js';
+import { replaceFileAtomically } from '../runtime/atomic-file.js';
+import { readProjectRuntimeContext } from '../runtime/project-runtime.js';
+import {
+ type GitRefTaskBundleV1,
+ parseGitRefTaskBundle,
+} from './git-ref-transport.js';
+
+export interface GitRefTaskRemoteBaseV1 {
+ schemaVersion: 1;
+ workspaceId: Ulid;
+ taskRef: TaskRef;
+ remoteRevision: number;
+ bundle: GitRefTaskBundleV1;
+}
+
+export async function readGitRefTaskRemoteBase(
+ projectRoot: string,
+ taskRef: TaskRef,
+): Promise {
+ const parsedTaskRef = parseTaskRefValue(taskRef);
+ const runtime = await readProjectRuntimeContext(projectRoot);
+ const target = remoteBasePath(projectRoot, parsedTaskRef);
+ try {
+ await assertRemoteBaseDirectory(projectRoot);
+ const before = await lstat(target);
+ if (!before.isFile() || before.isSymbolicLink()) {
+ throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE');
+ }
+ const state = parseGitRefTaskRemoteBase(
+ JSON.parse(await readFile(target, 'utf8')),
+ );
+ const after = await lstat(target);
+ if (
+ !after.isFile() ||
+ after.isSymbolicLink() ||
+ before.dev !== after.dev ||
+ before.ino !== after.ino
+ ) {
+ throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE');
+ }
+ if (
+ state.workspaceId !== runtime.workspaceId ||
+ !sameTaskRef(state.taskRef, parsedTaskRef)
+ ) {
+ return null;
+ }
+ return state;
+ } catch (error) {
+ if (isNotFound(error)) return null;
+ if (error instanceof SyntaxError) {
+ throw new Error('MANCODE_TRANSPORT_CACHE_CORRUPT');
+ }
+ throw error;
+ }
+}
+
+export async function recordGitRefTaskRemoteBase(
+ projectRoot: string,
+ remoteRevision: number,
+ bundle: GitRefTaskBundleV1,
+): Promise {
+ const parsedBundle = parseGitRefTaskBundle(bundle);
+ const revision = positiveInteger(remoteRevision);
+ const runtime = await readProjectRuntimeContext(projectRoot);
+ const state = parseGitRefTaskRemoteBase({
+ schemaVersion: 1,
+ workspaceId: runtime.workspaceId,
+ taskRef: parsedBundle.taskRef,
+ remoteRevision: revision,
+ bundle: parsedBundle,
+ });
+ const directory = await ensureRemoteBaseDirectory(projectRoot);
+ const target = remoteBasePath(projectRoot, parsedBundle.taskRef);
+ const temporary = path.join(
+ directory,
+ `.${parsedBundle.taskRef.taskId}.${process.pid}.${Date.now()}.tmp`,
+ );
+ await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, {
+ encoding: 'utf8',
+ flag: 'wx',
+ });
+ await replaceFileAtomically(temporary, target);
+ return state;
+}
+
+export function parseGitRefTaskRemoteBase(
+ value: unknown,
+): GitRefTaskRemoteBaseV1 {
+ assertRecord(value, 'git-ref task remote base');
+ assertKnownKeys(
+ value,
+ ['schemaVersion', 'workspaceId', 'taskRef', 'remoteRevision', 'bundle'],
+ 'git-ref task remote base',
+ );
+ if (value.schemaVersion !== 1) {
+ throw new Error('git-ref task remote base schemaVersion must be 1');
+ }
+ assertUlid(value.workspaceId, 'git-ref task remote base workspaceId');
+ const taskRef = parseTaskRefValue(value.taskRef);
+ const bundle = parseGitRefTaskBundle(value.bundle);
+ if (!sameTaskRef(taskRef, bundle.taskRef)) {
+ throw new Error('MANCODE_TRANSPORT_CACHE_IDENTITY_MISMATCH');
+ }
+ return {
+ schemaVersion: 1,
+ workspaceId: value.workspaceId,
+ taskRef,
+ remoteRevision: positiveInteger(value.remoteRevision),
+ bundle,
+ };
+}
+
+async function ensureRemoteBaseDirectory(projectRoot: string): Promise {
+ let current = path.resolve(projectRoot);
+ for (const segment of [
+ '.mancode',
+ 'local',
+ 'cache',
+ 'git-ref',
+ 'remote-bases',
+ ]) {
+ current = path.join(current, segment);
+ try {
+ await mkdir(current);
+ } catch (error) {
+ if (!isAlreadyExists(error)) throw error;
+ }
+ const entry = await lstat(current);
+ if (!entry.isDirectory() || entry.isSymbolicLink()) {
+ throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE');
+ }
+ }
+ return current;
+}
+
+async function assertRemoteBaseDirectory(projectRoot: string): Promise {
+ let current = path.resolve(projectRoot);
+ for (const segment of [
+ '.mancode',
+ 'local',
+ 'cache',
+ 'git-ref',
+ 'remote-bases',
+ ]) {
+ current = path.join(current, segment);
+ const entry = await lstat(current);
+ if (!entry.isDirectory() || entry.isSymbolicLink()) {
+ throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE');
+ }
+ }
+}
+
+function remoteBasePath(projectRoot: string, taskRef: TaskRef): string {
+ const parsed = parseTaskRefValue(taskRef);
+ return path.join(
+ path.resolve(projectRoot),
+ '.mancode',
+ 'local',
+ 'cache',
+ 'git-ref',
+ 'remote-bases',
+ `${parsed.taskId}.json`,
+ );
+}
+
+function positiveInteger(value: unknown): number {
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) {
+ throw new Error('MANCODE_TRANSPORT_REVISION_INVALID');
+ }
+ return value;
+}
+
+function isAlreadyExists(error: unknown): error is NodeJS.ErrnoException {
+ return (
+ typeof error === 'object' &&
+ error !== null &&
+ 'code' in error &&
+ (error as NodeJS.ErrnoException).code === 'EEXIST'
+ );
+}
+
+function isNotFound(error: unknown): error is NodeJS.ErrnoException {
+ return (
+ typeof error === 'object' &&
+ error !== null &&
+ 'code' in error &&
+ (error as NodeJS.ErrnoException).code === 'ENOENT'
+ );
+}
diff --git a/src/team/git-ref-transport.ts b/src/team/git-ref-transport.ts
index d90064f..4ebeef7 100644
--- a/src/team/git-ref-transport.ts
+++ b/src/team/git-ref-transport.ts
@@ -207,6 +207,7 @@ export interface MutateGitRefCoordinationInput {
taskRef: TaskRef;
expectedRemoteRevision: number;
expectedOwnershipEpoch: number;
+ expectedTaskBundleDigest: string | null;
ownershipFence: GitRefOwnershipFenceV1;
claims: ClaimV1[];
handoffs: HandoffV1[];
@@ -511,6 +512,15 @@ export class GitRefTeamManifestStore {
const previousFence = base.ownershipFences.find((candidate) =>
sameTaskRef(candidate.taskRef, taskRef),
);
+ const previousTaskBundle = base.taskBundles.find((bundle) =>
+ sameTaskRef(bundle.taskRef, taskRef),
+ );
+ if (
+ (previousTaskBundle?.bundleDigest ?? null) !==
+ input.expectedTaskBundleDigest
+ ) {
+ throw new Error('MANCODE_TASK_BUNDLE_DIVERGED');
+ }
if (
!base.actorProfiles.some((profile) => profile.actorId === input.actorId)
) {
@@ -547,9 +557,7 @@ export class GitRefTeamManifestStore {
previousHandoffs: base.handoffs.filter((handoff) =>
sameTaskRef(handoff.taskRef, taskRef),
),
- previousTaskBundle: base.taskBundles.find((bundle) =>
- sameTaskRef(bundle.taskRef, taskRef),
- ),
+ previousTaskBundle,
expectedOwnershipEpoch,
nextRevision,
fence,
diff --git a/src/team/git-ref-workflow-operation.ts b/src/team/git-ref-workflow-operation.ts
index 4988671..a2f12a7 100644
--- a/src/team/git-ref-workflow-operation.ts
+++ b/src/team/git-ref-workflow-operation.ts
@@ -580,6 +580,10 @@ async function publishTaskMutation(
taskRef: input.context.taskRef,
expectedRemoteRevision: input.manifest.revision,
expectedOwnershipEpoch: input.fence.ownershipEpoch,
+ expectedTaskBundleDigest:
+ input.manifest.taskBundles.find((bundle) =>
+ sameTaskRef(bundle.taskRef, input.context.taskRef),
+ )?.bundleDigest ?? null,
ownershipFence: fence,
claims: input.claims,
handoffs,
diff --git a/src/team/git-ref-workflow-repair.ts b/src/team/git-ref-workflow-repair.ts
index a670420..edb365a 100644
--- a/src/team/git-ref-workflow-repair.ts
+++ b/src/team/git-ref-workflow-repair.ts
@@ -243,6 +243,7 @@ export async function recoverGitRefWorkflowRepair(
bundle: targetBundle,
predecessorBundle: journal.prepared.predecessorBundle,
pendingMetadata: journal.pendingMetadata,
+ taskLockHeld: true,
operationId: createUlid(),
});
journal = await transitionJournal(
diff --git a/tests/git-ref-coordination-contracts.test.ts b/tests/git-ref-coordination-contracts.test.ts
index 07c5635..7081880 100644
--- a/tests/git-ref-coordination-contracts.test.ts
+++ b/tests/git-ref-coordination-contracts.test.ts
@@ -83,6 +83,7 @@ describe('git-ref coordination domain contracts', () => {
taskRef: TASK_REF,
expectedRemoteRevision: 1,
expectedOwnershipEpoch: 0,
+ expectedPredecessorBundleDigest: null,
taskBundle: initialBundle,
now: NOW,
});
@@ -100,6 +101,90 @@ describe('git-ref coordination domain contracts', () => {
});
});
+ it('advances an owner code ref and refreshes only the owner claim in one CAS', () => {
+ const current = baseManifest({ claims: [activeClaim()] });
+ const previous = current.taskBundles[0];
+ if (previous === undefined) throw new Error('missing current task bundle');
+ const nextBundle = bundleWithCodeHead(previous, 'def5678');
+
+ const prepared = prepareGitRefCoordinationMutation(current, {
+ kind: 'ownership_fence',
+ operationId: id(24),
+ actorId: OWNER_ID,
+ taskRef: TASK_REF,
+ expectedRemoteRevision: 1,
+ expectedOwnershipEpoch: 1,
+ expectedPredecessorBundleDigest: previous.bundleDigest,
+ taskBundle: nextBundle,
+ now: NOW,
+ });
+
+ expect(prepared.ownershipFence).toMatchObject({
+ taskRevision: previous.taskRevision,
+ aggregateDigest: previous.aggregateDigest,
+ ownerActorId: OWNER_ID,
+ });
+ expect(prepared.claims[0]).toMatchObject({
+ claimId: CLAIM_ID,
+ revision: 2,
+ lastValidatedTaskRevision: nextBundle.taskRevision,
+ lastValidatedCodeRef: nextBundle.codeRef,
+ authority: { remoteRevision: '2' },
+ });
+
+ expect(() =>
+ prepareGitRefCoordinationMutation(current, {
+ kind: 'ownership_fence',
+ operationId: id(25),
+ actorId: OWNER_ID,
+ taskRef: TASK_REF,
+ expectedRemoteRevision: 1,
+ expectedOwnershipEpoch: 1,
+ expectedPredecessorBundleDigest: bundleWithCodeHead(previous, 'fedcba9')
+ .bundleDigest,
+ taskBundle: nextBundle,
+ now: NOW,
+ }),
+ ).toThrow('MANCODE_TASK_BUNDLE_DIVERGED');
+
+ const participantClaim = baseManifest({
+ claims: [activeClaim({ ownerActorId: RECEIVER_ID })],
+ });
+ const participantPrepared = prepareGitRefCoordinationMutation(
+ participantClaim,
+ {
+ kind: 'ownership_fence',
+ operationId: id(26),
+ actorId: OWNER_ID,
+ taskRef: TASK_REF,
+ expectedRemoteRevision: 1,
+ expectedOwnershipEpoch: 1,
+ expectedPredecessorBundleDigest: previous.bundleDigest,
+ taskBundle: nextBundle,
+ now: NOW,
+ },
+ );
+ expect(participantPrepared.claims[0]).toEqual(participantClaim.claims[0]);
+ const participantRevalidated = prepareGitRefCoordinationMutation(
+ commitPrepared(participantClaim, participantPrepared),
+ {
+ kind: 'claim_revalidate',
+ operationId: id(27),
+ actorId: RECEIVER_ID,
+ taskRef: TASK_REF,
+ expectedRemoteRevision: 2,
+ expectedOwnershipEpoch: 1,
+ claimId: CLAIM_ID,
+ expectedClaimRevision: 1,
+ now: NOW,
+ },
+ );
+ expect(participantRevalidated.claims[0]).toMatchObject({
+ revision: 2,
+ lastValidatedCodeRef: nextBundle.codeRef,
+ });
+ });
+
it('materializes remote handoffs as fetched without changing business state', () => {
const current = {
...baseManifest({
@@ -171,6 +256,8 @@ describe('git-ref coordination domain contracts', () => {
taskRef: TASK_REF,
expectedRemoteRevision: 1,
expectedOwnershipEpoch: 1,
+ expectedPredecessorBundleDigest:
+ current.taskBundles[0]?.bundleDigest ?? null,
taskBundle: bundle(
metadata({ task: 'A divergent task at the same remote revision.' }),
),
@@ -223,6 +310,8 @@ describe('git-ref coordination domain contracts', () => {
taskRef: TASK_REF,
expectedRemoteRevision: 3,
expectedOwnershipEpoch: 1,
+ expectedPredecessorBundleDigest:
+ current.taskBundles[0]?.bundleDigest ?? null,
taskBundle: bundle(metadata({ revision: 8 })),
now: NOW,
});
@@ -820,6 +909,18 @@ function bundle(value: WorkflowMetadataV3): GitRefTaskBundleV1 {
return { ...body, bundleDigest: digestCanonicalJson(body) };
}
+function bundleWithCodeHead(
+ previous: GitRefTaskBundleV1,
+ head: string,
+): GitRefTaskBundleV1 {
+ const body = { ...previous, codeRef: { ...previous.codeRef, head } };
+ const { bundleDigest: _bundleDigest, ...withoutDigest } = body;
+ return {
+ ...withoutDigest,
+ bundleDigest: digestCanonicalJson(withoutDigest),
+ };
+}
+
function pendingClaim(overrides: Partial = {}): ClaimV1 {
const scope = {
paths: ['src/auth/token.ts'],
diff --git a/tests/git-ref-cross-clone-e2e.test.ts b/tests/git-ref-cross-clone-e2e.test.ts
index 73e4902..77589a3 100644
--- a/tests/git-ref-cross-clone-e2e.test.ts
+++ b/tests/git-ref-cross-clone-e2e.test.ts
@@ -1,5 +1,5 @@
import { execFile as execFileCallback } from 'node:child_process';
-import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
+import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';
@@ -52,6 +52,7 @@ import {
mutateGitRefHandoff,
syncGitRefTask,
} from '../src/team/git-ref-operation.js';
+import { readGitRefTaskRemoteBase } from '../src/team/git-ref-task-base.js';
import type {
GitRefOwnershipFenceV1,
GitRefTaskBundleV1,
@@ -314,6 +315,297 @@ describe('git-ref coordination across independent clones', () => {
});
}, 20_000);
+ it('lets the owner checkpoint and sync after a claimed code commit', async () => {
+ const fixture = await createFixture();
+ const created = await createRemoteTask(fixture.cloneA, fixture.codeHead);
+ const storeA = await strictStore(fixture.cloneA);
+ const initialBundle = await taskBundle(fixture.cloneA);
+ const establishOperationId = id(80);
+ await storeA.establishCoordinationAuthority({
+ operationId: establishOperationId,
+ actorId: ACTOR_A,
+ expectedRemoteRevision: 0,
+ expectedPriorTransportEpoch: null,
+ targetTransportEpoch: 2,
+ actorProfiles: fixture.profiles,
+ ownershipFences: [
+ ownershipFence(initialBundle, ACTOR_A, 1, establishOperationId),
+ ],
+ claims: [],
+ handoffs: [],
+ taskBundles: [initialBundle],
+ });
+ const acquired = await acquireGitRefClaim({
+ projectRoot: fixture.cloneA,
+ taskRef: created.taskRef,
+ sessionId: SESSION_A,
+ expectedTaskRevision: created.metadata.revision,
+ scope: scope('src/auth/**'),
+ claimId: id(81),
+ operationId: id(82),
+ now: at(2),
+ });
+
+ await git(fixture.cloneA, ['push', 'origin', 'main']);
+ await git(fixture.cloneB, ['fetch', 'origin']);
+ await captureJson(() =>
+ teamSyncPull(fixture.cloneB, {
+ task: formatTaskRef(created.taskRef),
+ json: true,
+ }),
+ );
+ await git(fixture.cloneB, ['merge', '--ff-only', 'origin/main']);
+ await rm(
+ path.join(
+ fixture.cloneB,
+ '.mancode',
+ 'local',
+ 'cache',
+ 'git-ref',
+ 'remote-bases',
+ `${created.taskRef.taskId}.json`,
+ ),
+ );
+ await expect(
+ acquireGitRefClaim({
+ projectRoot: fixture.cloneB,
+ taskRef: created.taskRef,
+ sessionId: SESSION_B,
+ expectedTaskRevision: created.metadata.revision,
+ scope: scope('src/auth/**'),
+ claimId: id(86),
+ operationId: id(87),
+ now: at(2),
+ }),
+ ).rejects.toThrow('MANCODE_SCOPE_CONFLICT');
+
+ await mkdir(path.join(fixture.cloneA, 'src', 'auth'), { recursive: true });
+ await writeFile(
+ path.join(fixture.cloneA, 'src', 'auth', 'token.ts'),
+ "export const token = 'ready';\n",
+ );
+ await git(fixture.cloneA, ['add', 'src/auth/token.ts']);
+ await git(fixture.cloneA, ['commit', '-m', 'implement claimed code']);
+ const codeHead = await gitOutput(fixture.cloneA, ['rev-parse', 'HEAD']);
+
+ const checkpoint = await createV3Checkpoint({
+ projectRoot: fixture.cloneA,
+ taskRef: created.taskRef,
+ sessionId: SESSION_A,
+ expectedTaskRevision: created.metadata.revision,
+ kind: 'handoff_offered',
+ summary: 'The claimed implementation is committed and ready to hand off.',
+ nextAction: 'Pull the commit and accept the handoff.',
+ checkpointId: id(83),
+ operationId: id(84),
+ now: at(3),
+ });
+ expect(checkpoint.taskHeadFence?.codeRef.head).toBe(codeHead);
+
+ const synced = await syncGitRefTask({
+ projectRoot: fixture.cloneA,
+ taskRef: created.taskRef,
+ sessionId: SESSION_A,
+ expectedTaskRevision: checkpoint.metadata.revision,
+ operationId: id(85),
+ now: at(4),
+ });
+ expect(synced).toMatchObject({
+ changed: true,
+ remoteRevision: 3,
+ bundle: { codeRef: { head: codeHead } },
+ });
+ await expect(storeA.pull()).resolves.toMatchObject({
+ manifest: {
+ claims: [
+ {
+ claimId: acquired.claim.claimId,
+ revision: acquired.claim.revision + 1,
+ lastValidatedTaskRevision: checkpoint.metadata.revision,
+ lastValidatedCodeRef: { head: codeHead },
+ },
+ ],
+ },
+ });
+
+ const unchanged = await syncGitRefTask({
+ projectRoot: fixture.cloneA,
+ taskRef: created.taskRef,
+ sessionId: SESSION_A,
+ expectedTaskRevision: checkpoint.metadata.revision,
+ operationId: id(115),
+ now: at(5),
+ });
+ expect(unchanged.changed).toBe(false);
+ const exactRemoteBase = await readGitRefTaskRemoteBase(
+ fixture.cloneA,
+ created.taskRef,
+ );
+ const remoteAfterUnchangedSync = await storeA.pull();
+ expect(exactRemoteBase?.bundle.bundleDigest).toBe(
+ remoteAfterUnchangedSync.manifest?.taskBundles[0]?.bundleDigest,
+ );
+
+ await git(fixture.cloneA, ['push', 'origin', 'main']);
+ const drafted = await createGitRefHandoffDraft({
+ projectRoot: fixture.cloneA,
+ taskRef: created.taskRef,
+ sessionId: SESSION_A,
+ expectedTaskRevision: checkpoint.metadata.revision,
+ toActorId: ACTOR_B,
+ handoffId: id(88),
+ operationId: id(89),
+ now: at(5),
+ });
+ const offered = await mutateGitRefHandoff({
+ projectRoot: fixture.cloneA,
+ handoffId: drafted.handoff.handoffId,
+ sessionId: SESSION_A,
+ expectedHandoffRevision: drafted.handoff.revision,
+ mutation: { kind: 'offer' },
+ operationId: id(90),
+ now: at(6),
+ });
+
+ const stalePull = await captureJson(() =>
+ teamSyncPull(fixture.cloneB, {
+ task: formatTaskRef(created.taskRef),
+ json: true,
+ }),
+ );
+ expect(stalePull.value).toMatchObject({
+ materializedBundles: [{ status: 'quarantined', codeReachable: false }],
+ });
+ await expect(
+ acceptGitRefHandoffWithRepair({
+ projectRoot: fixture.cloneB,
+ handoffId: drafted.handoff.handoffId,
+ sessionId: SESSION_B,
+ expectedHandoffRevision: offered.handoff.revision,
+ operationId: id(91),
+ now: at(7),
+ }),
+ ).rejects.toThrow('MANCODE_EXPECTED_REVISION_CONFLICT');
+
+ await git(fixture.cloneB, ['pull', '--ff-only']);
+ const currentPull = await captureJson(() =>
+ teamSyncPull(fixture.cloneB, {
+ task: formatTaskRef(created.taskRef),
+ json: true,
+ }),
+ );
+ expect(currentPull.value).toMatchObject({
+ materializedBundles: [
+ {
+ status: 'updated',
+ codeReachable: true,
+ taskRevision: checkpoint.metadata.revision,
+ },
+ ],
+ });
+ const accepted = await acceptGitRefHandoffWithRepair({
+ projectRoot: fixture.cloneB,
+ handoffId: drafted.handoff.handoffId,
+ sessionId: SESSION_B,
+ expectedHandoffRevision: offered.handoff.revision,
+ operationId: id(92),
+ now: at(8),
+ });
+ expect(accepted).toMatchObject({
+ ownershipEpoch: 1,
+ handoff: { state: 'accepted' },
+ taskBundle: { ownershipEpoch: 1 },
+ forwardRepair: { ownerActorId: ACTOR_B },
+ });
+ }, 20_000);
+
+ it('rejects a stale owner clone instead of overwriting a newer code ref', async () => {
+ const fixture = await createFixture();
+ const created = await createRemoteTask(fixture.cloneA, fixture.codeHead);
+ const storeA = await strictStore(fixture.cloneA);
+ const initialBundle = await taskBundle(fixture.cloneA);
+ const establishOperationId = id(100);
+ await storeA.establishCoordinationAuthority({
+ operationId: establishOperationId,
+ actorId: ACTOR_A,
+ expectedRemoteRevision: 0,
+ expectedPriorTransportEpoch: null,
+ targetTransportEpoch: 2,
+ actorProfiles: fixture.profiles,
+ ownershipFences: [
+ ownershipFence(initialBundle, ACTOR_A, 1, establishOperationId),
+ ],
+ claims: [],
+ handoffs: [],
+ taskBundles: [initialBundle],
+ });
+ await captureJson(() =>
+ teamSyncPull(fixture.cloneA, {
+ task: formatTaskRef(created.taskRef),
+ json: true,
+ }),
+ );
+ await git(fixture.cloneA, ['push', 'origin', 'main']);
+ await git(fixture.cloneB, ['fetch', 'origin']);
+ await captureJson(() =>
+ teamSyncPull(fixture.cloneB, {
+ task: formatTaskRef(created.taskRef),
+ json: true,
+ }),
+ );
+ await git(fixture.cloneB, ['merge', '--ff-only', 'origin/main']);
+ await createSession(fixture.cloneB, {
+ actorId: ACTOR_A,
+ sessionId: id(101),
+ client: 'vitest-a-stale',
+ identitySource: 'explicit',
+ now: NOW,
+ });
+
+ await writeFile(
+ path.join(fixture.cloneA, 'src-a.ts'),
+ 'export const a = 1;\n',
+ );
+ await git(fixture.cloneA, ['add', 'src-a.ts']);
+ await git(fixture.cloneA, ['commit', '-m', 'advance owner code ref']);
+ await syncGitRefTask({
+ projectRoot: fixture.cloneA,
+ taskRef: created.taskRef,
+ sessionId: SESSION_A,
+ expectedTaskRevision: created.metadata.revision,
+ operationId: id(102),
+ now: at(2),
+ });
+
+ await writeFile(
+ path.join(fixture.cloneB, 'src-b.ts'),
+ 'export const b = 1;\n',
+ );
+ await git(fixture.cloneB, ['add', 'src-b.ts']);
+ await git(fixture.cloneB, ['commit', '-m', 'diverge stale owner code']);
+ await expect(
+ syncGitRefTask({
+ projectRoot: fixture.cloneB,
+ taskRef: created.taskRef,
+ sessionId: id(101),
+ expectedTaskRevision: created.metadata.revision,
+ operationId: id(103),
+ now: at(3),
+ }),
+ ).rejects.toThrow('MANCODE_TASK_BUNDLE_DIVERGED');
+ await expect(storeA.pull()).resolves.toMatchObject({
+ manifest: {
+ taskBundles: [
+ {
+ codeRef: {
+ head: await gitOutput(fixture.cloneA, ['rev-parse', 'HEAD']),
+ },
+ },
+ ],
+ },
+ });
+ }, 20_000);
+
it('fences claims, materializes a quarantined bundle, and transfers ownership once', async () => {
const fixture = await createFixture();
const created = await createRemoteTask(fixture.cloneA, fixture.codeHead);
diff --git a/tests/git-ref-manifest-authority-contracts.test.ts b/tests/git-ref-manifest-authority-contracts.test.ts
index 205f896..805b7c3 100644
--- a/tests/git-ref-manifest-authority-contracts.test.ts
+++ b/tests/git-ref-manifest-authority-contracts.test.ts
@@ -111,6 +111,7 @@ describe('git-ref remote manifest contracts', () => {
taskRef: taskRef(),
expectedRemoteRevision: 1,
expectedOwnershipEpoch: 0,
+ expectedTaskBundleDigest: null,
ownershipFence: fence(id(21), 2),
claims: [],
handoffs: [],
@@ -124,6 +125,7 @@ describe('git-ref remote manifest contracts', () => {
taskRef: taskRef(),
expectedRemoteRevision: 1,
expectedOwnershipEpoch: 0,
+ expectedTaskBundleDigest: null,
ownershipFence: fence(id(22), 2),
claims: [],
handoffs: [],
@@ -137,6 +139,7 @@ describe('git-ref remote manifest contracts', () => {
taskRef: taskRef(),
expectedRemoteRevision: 2,
expectedOwnershipEpoch: 0,
+ expectedTaskBundleDigest: null,
ownershipFence: { ...fence(id(23), 3), ownerActorId: id(99) },
claims: [],
handoffs: [],
diff --git a/tests/git-ref-materialization-contracts.test.ts b/tests/git-ref-materialization-contracts.test.ts
index 97277a1..6ce22dd 100644
--- a/tests/git-ref-materialization-contracts.test.ts
+++ b/tests/git-ref-materialization-contracts.test.ts
@@ -9,7 +9,11 @@ import { createV3Checkpoint } from '../src/context/checkpoint-create.js';
import { type Ulid, createUlid } from '../src/context/ids.js';
import { V3ContextStore } from '../src/context/store.js';
import { createV3Workflow } from '../src/context/workflow-create.js';
+import { resolveCoordinationEntityHomeStore } from '../src/runtime/entity-home-store.js';
+import { acquireEntityLocks } from '../src/runtime/local-lock.js';
+import { readProjectRuntimeContext } from '../src/runtime/project-runtime.js';
import { createSession } from '../src/runtime/session.js';
+import { taskEntityKey } from '../src/runtime/task-operation.js';
import {
createLocalActor,
createSharedActorProfile,
@@ -112,6 +116,165 @@ describe('git-ref task bundle materialization', () => {
}),
).rejects.toThrow('MANCODE_SPLIT_BRAIN');
});
+
+ it('serializes materialization through the canonical task lock', async () => {
+ const source = await bootstrap('source-lock', id(60), id(61));
+ const target = await bootstrap('target-lock', id(62), id(63));
+ await createSharedWorkflow(source.root, source.sessionId);
+ const remote = await bundle(source.root);
+ const runtime = await readProjectRuntimeContext(target.root);
+ const store = resolveCoordinationEntityHomeStore(
+ runtime.entityHomeStoreContext,
+ );
+ const locks = await acquireEntityLocks(store, id(64), [
+ taskEntityKey(remote.taskRef),
+ ]);
+
+ try {
+ await expect(
+ materializeGitRefTaskBundle({
+ projectRoot: target.root,
+ remoteRevision: 1,
+ ownershipFence: remoteFence(remote, 1, id(65)),
+ bundle: remote,
+ operationId: id(66),
+ now: NOW,
+ }),
+ ).rejects.toThrow('MANCODE_LOCK_HELD');
+ } finally {
+ await Promise.all(locks.map((lock) => lock.release()));
+ }
+ });
+
+ it('advances the fence when Git already supplied the target aggregate', async () => {
+ const source = await bootstrap('source-git-update', id(50), id(51));
+ const target = await bootstrap('target-git-update', id(52), id(53));
+ const created = await createSharedWorkflow(source.root, source.sessionId);
+ const first = await bundle(source.root);
+
+ await materializeGitRefTaskBundle({
+ projectRoot: target.root,
+ remoteRevision: 1,
+ ownershipFence: remoteFence(first, 1, id(54)),
+ bundle: first,
+ operationId: id(55),
+ now: NOW,
+ });
+
+ await createV3Checkpoint({
+ projectRoot: source.root,
+ taskRef: created.taskRef,
+ sessionId: source.sessionId,
+ expectedTaskRevision: created.metadata.revision,
+ kind: 'diagnostic_started',
+ summary: 'Advance the task before the other clone pulls Git.',
+ operationId: id(56),
+ checkpointId: id(57),
+ now: new Date('2026-07-18T10:01:00.000Z'),
+ });
+ const second = await bundle(source.root);
+
+ // Simulate a normal Git pull updating tracked task files before team sync.
+ await writeBundleArtifacts(target.root, second);
+ const adopted = await materializeGitRefTaskBundle({
+ projectRoot: target.root,
+ remoteRevision: 2,
+ ownershipFence: remoteFence(second, 2, id(58)),
+ bundle: second,
+ operationId: id(59),
+ now: new Date('2026-07-18T10:02:00.000Z'),
+ });
+ expect(adopted).toMatchObject({
+ status: 'unchanged',
+ taskRevision: second.taskRevision,
+ taskHeadFence: {
+ taskRevision: second.taskRevision,
+ remoteRevision: 2,
+ },
+ });
+
+ const repeated = await materializeGitRefTaskBundle({
+ projectRoot: target.root,
+ remoteRevision: 2,
+ ownershipFence: remoteFence(second, 2, id(58)),
+ bundle: second,
+ operationId: id(67),
+ now: new Date('2026-07-18T10:03:00.000Z'),
+ });
+ expect(repeated.taskHeadFence).toEqual(adopted.taskHeadFence);
+
+ const refreshed = await materializeGitRefTaskBundle({
+ projectRoot: target.root,
+ remoteRevision: 3,
+ ownershipFence: remoteFence(second, 2, id(58)),
+ bundle: second,
+ operationId: id(68),
+ now: new Date('2026-07-18T10:04:00.000Z'),
+ });
+ expect(refreshed.taskHeadFence).toMatchObject({
+ fenceRevision: adopted.taskHeadFence.fenceRevision + 1,
+ remoteRevision: 3,
+ });
+ });
+
+ it('does not treat an unsynced local revision as a cached remote predecessor', async () => {
+ const source = await bootstrap('source-base', id(30), id(31));
+ const target = await bootstrap('target-base', id(30), id(32));
+ const created = await createSharedWorkflow(source.root, source.sessionId);
+ const first = await bundle(source.root);
+
+ await expect(
+ materializeGitRefTaskBundle({
+ projectRoot: target.root,
+ remoteRevision: 1,
+ ownershipFence: remoteFence(first, 1, id(33)),
+ bundle: first,
+ operationId: id(34),
+ now: NOW,
+ }),
+ ).resolves.toMatchObject({ status: 'created' });
+ await execFile('git', ['fetch', source.root, 'HEAD'], { cwd: target.root });
+ await execFile('git', ['checkout', '--detach', 'FETCH_HEAD'], {
+ cwd: target.root,
+ });
+ await createV3Checkpoint({
+ projectRoot: target.root,
+ taskRef: created.taskRef,
+ sessionId: target.sessionId,
+ expectedTaskRevision: created.metadata.revision,
+ kind: 'diagnostic_started',
+ summary: 'Create an unsynced local predecessor.',
+ operationId: id(35),
+ checkpointId: id(36),
+ now: new Date('2026-07-18T10:01:00.000Z'),
+ });
+
+ await createV3Checkpoint({
+ projectRoot: source.root,
+ taskRef: created.taskRef,
+ sessionId: source.sessionId,
+ expectedTaskRevision: created.metadata.revision,
+ kind: 'diagnostic_started',
+ summary: 'Advance the remote task independently.',
+ operationId: id(37),
+ checkpointId: id(38),
+ now: new Date('2026-07-18T10:01:00.000Z'),
+ });
+ const second = await bundle(source.root);
+
+ await expect(
+ materializeGitRefTaskBundle({
+ projectRoot: target.root,
+ remoteRevision: 2,
+ ownershipFence: remoteFence(second, 2, id(39)),
+ bundle: second,
+ // Simulates a quarantine pull that cached the target before code was reachable.
+ predecessorBundle: second,
+ operationId: id(40),
+ now: new Date('2026-07-18T10:02:00.000Z'),
+ }),
+ ).rejects.toThrow('MANCODE_SPLIT_BRAIN');
+ });
});
async function bootstrap(label: string, actorId: Ulid, sessionId: Ulid) {
@@ -188,6 +351,29 @@ async function bundle(root: string): Promise {
});
}
+async function writeBundleArtifacts(
+ root: string,
+ bundle: GitRefTaskBundleV1,
+): Promise {
+ const taskRoot = path.join(
+ root,
+ '.mancode',
+ 'shared',
+ 'workflows',
+ bundle.taskRef.taskId,
+ );
+ for (const artifact of bundle.artifacts) {
+ const target = path.join(taskRoot, artifact.relativePath);
+ await mkdir(path.dirname(target), { recursive: true });
+ await writeFile(
+ target,
+ typeof artifact.content === 'string'
+ ? artifact.content
+ : `${JSON.stringify(artifact.content, null, 2)}\n`,
+ );
+ }
+}
+
function remoteFence(
bundle: GitRefTaskBundleV1,
remoteRevision: number,
diff --git a/website/docs.html b/website/docs.html
index 5b32e8d..71e8850 100644
--- a/website/docs.html
+++ b/website/docs.html
@@ -24,7 +24,7 @@