Skip to content
Merged
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
6 changes: 3 additions & 3 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
<p align="center">
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=flat-square" alt="License: AGPL-3.0" /></a>
<a href="https://www.npmjs.com/package/mancode"><img src="https://img.shields.io/npm/v/mancode?style=flat-square" alt="npm version" /></a>
<img src="https://img.shields.io/badge/status-Continuity%20v0.3.16-2f855a?style=flat-square" alt="Status: mancode Continuity v0.3.16" />
<img src="https://img.shields.io/badge/status-Continuity%20v0.3.18-2f855a?style=flat-square" alt="Status: mancode Continuity v0.3.18" />
<img src="https://img.shields.io/badge/platforms-Claude%20Code%20%7C%20Cursor%20%7C%20Codex%20%7C%20Copilot%20%7C%20ZCode-5865F2?style=flat-square" alt="Platforms: Claude Code, Cursor, Codex in ChatGPT desktop and CLI, GitHub Copilot, ZCode" />
</p>

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -452,7 +452,7 @@ mancode version
Simplified output:

```text
mancode v0.3.16
mancode v0.3.18

Project: my-app
Runtime: ready
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
<p align="center">
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=flat-square" alt="许可证:AGPL-3.0" /></a>
<a href="https://www.npmjs.com/package/mancode"><img src="https://img.shields.io/npm/v/mancode?style=flat-square" alt="npm 版本" /></a>
<img src="https://img.shields.io/badge/status-Continuity%20v0.3.16-2f855a?style=flat-square" alt="状态:mancode Continuity v0.3.16" />
<img src="https://img.shields.io/badge/status-Continuity%20v0.3.18-2f855a?style=flat-square" alt="状态:mancode Continuity v0.3.18" />
<img src="https://img.shields.io/badge/platforms-Claude%20Code%20%7C%20Cursor%20%7C%20Codex%20%7C%20Copilot%20%7C%20ZCode-5865F2?style=flat-square" alt="平台:Claude Code、Cursor、ChatGPT 桌面端 Codex、Codex CLI、GitHub Copilot、ZCode" />
</p>

Expand Down Expand Up @@ -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、
Expand Down Expand Up @@ -404,7 +404,7 @@ mancode version
以下是简化输出示例:

```text
mancode v0.3.16
mancode v0.3.18

Project: my-app
Runtime: ready
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
55 changes: 50 additions & 5 deletions src/runtime/task-operation.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -251,6 +267,35 @@ export async function openV3TaskOperation(
}
}

async function assertOwnerCodeHeadAdvance(input: {
projectRoot: string;
task: StoredTaskSnapshot;
fence: NonNullable<StoredCoordinationSnapshot['taskHeadFence']>;
codeHead: string;
actorId: Ulid;
originalError: unknown;
}): Promise<void> {
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.
Expand Down
107 changes: 105 additions & 2 deletions src/team/git-ref-bundle.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<GitRefTaskBundleV1 | null> {
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,
Expand Down Expand Up @@ -185,6 +265,20 @@ async function ensureFixedDirectory(
}
}

async function assertFixedDirectory(
projectRoot: string,
segments: string[],
): Promise<void> {
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' &&
Expand All @@ -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'
);
}
Loading
Loading