@goodtek/vibeops 2.5.2 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +55 -0
- package/README.md +89 -4
- package/dist/commands/pull.js +16 -0
- package/dist/commands/task-add.js +175 -97
- package/dist/commands/task-del.js +7 -0
- package/dist/commands/task-merge.js +31 -4
- package/dist/commands/task-release.js +28 -4
- package/dist/commands/task-ship.js +7 -0
- package/dist/commands/task-sync.js +60 -28
- package/dist/lib/check-rollup.js +128 -0
- package/dist/lib/config.js +3 -0
- package/dist/lib/git-integration-sync.js +77 -71
- package/dist/lib/git-worktree.js +214 -0
- package/dist/lib/git.js +63 -54
- package/dist/lib/merge-config.js +125 -0
- package/dist/lib/merge-request-readiness.js +20 -1
- package/dist/lib/pr-create.js +293 -103
- package/dist/lib/task-del.js +7 -5
- package/dist/lib/task-id-allocation.js +192 -0
- package/dist/lib/task-lock.js +356 -0
- package/dist/lib/task-new-cycle.js +12 -1
- package/dist/lib/task-push-pr.js +7 -0
- package/dist/lib/task-reship.js +27 -12
- package/dist/lib/task-start.js +35 -26
- package/package.json +1 -1
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { readdir, realpath } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
3
|
+
import { gitRemoteUrl, runGit } from "./git.js";
|
|
4
|
+
import { currentWorktreeRoot, listWorktrees } from "./git-worktree.js";
|
|
5
|
+
/**
|
|
6
|
+
* Collision-free TASK ids (D-006).
|
|
7
|
+
*
|
|
8
|
+
* next id = 1 + max TASK number found in ANY of:
|
|
9
|
+
* 1. local `docs/tasks` (this worktree, including uncommitted files);
|
|
10
|
+
* 2. `docs/tasks` on `<remote>/<integration>` (`git ls-tree`), else local `<integration>`;
|
|
11
|
+
* 3. VibeOps task branches — local heads and `<remote>/task/*` (refreshed with
|
|
12
|
+
* `git fetch --prune <remote> +refs/heads/task/*:refs/remotes/<remote>/task/*`) —
|
|
13
|
+
* counted only when the branch name has the generated form AND the branch's
|
|
14
|
+
* tree contains that TASK file (so `task/2026-q4-plan` does not inflate ids);
|
|
15
|
+
* 4. `docs/tasks` in every worktree from `git worktree list` (covers task branches
|
|
16
|
+
* whose TASK file is not committed yet).
|
|
17
|
+
*
|
|
18
|
+
* A configured remote whose task branches cannot be fetched is an error (fail
|
|
19
|
+
* closed) — an id could otherwise collide with an unmerged branch.
|
|
20
|
+
*/
|
|
21
|
+
const TASK_FILE_RE = /^TASK-(\d+)(?:-|\.md$)/i;
|
|
22
|
+
/**
|
|
23
|
+
* Generated task branch form: `task/<NNN>` or `task/<NNN>-<slug>`, where NNN is the
|
|
24
|
+
* zero-padded TASK number (≥ 3 digits, `formatTaskId`) and slug is `slugify` output
|
|
25
|
+
* (`[a-z0-9]+` joined by single hyphens) — i.e. `branchNameForTaskFile` of
|
|
26
|
+
* `TASK-<NNN>-<slug>.md`.
|
|
27
|
+
*/
|
|
28
|
+
export const GENERATED_TASK_BRANCH_RE = /^task\/(\d{3,})(?:-([a-z0-9]+(?:-[a-z0-9]+)*))?$/;
|
|
29
|
+
export function taskNumberFromFilename(name) {
|
|
30
|
+
const m = TASK_FILE_RE.exec(name.trim());
|
|
31
|
+
return m ? Number.parseInt(m[1], 10) : null;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* TASK number of a branch in the generated form (accepts `refs/heads/` and
|
|
35
|
+
* `refs/remotes/<remote>/` prefixes), else null.
|
|
36
|
+
*/
|
|
37
|
+
export function taskNumberFromBranch(ref) {
|
|
38
|
+
const name = ref
|
|
39
|
+
.trim()
|
|
40
|
+
.replace(/^refs\/heads\//, "")
|
|
41
|
+
.replace(/^refs\/remotes\/[^/]+\//, "");
|
|
42
|
+
const m = GENERATED_TASK_BRANCH_RE.exec(name);
|
|
43
|
+
return m ? Number.parseInt(m[1], 10) : null;
|
|
44
|
+
}
|
|
45
|
+
export class TaskIdAllocationError extends Error {
|
|
46
|
+
constructor(message) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = "TaskIdAllocationError";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function maxInDir(dir) {
|
|
52
|
+
let max = 0;
|
|
53
|
+
let names;
|
|
54
|
+
try {
|
|
55
|
+
names = await readdir(dir);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
for (const n of names) {
|
|
61
|
+
const v = taskNumberFromFilename(n);
|
|
62
|
+
if (v !== null)
|
|
63
|
+
max = Math.max(max, v);
|
|
64
|
+
}
|
|
65
|
+
return max;
|
|
66
|
+
}
|
|
67
|
+
function maxOf(values) {
|
|
68
|
+
let max = 0;
|
|
69
|
+
for (const v of values)
|
|
70
|
+
if (v !== null)
|
|
71
|
+
max = Math.max(max, v);
|
|
72
|
+
return max;
|
|
73
|
+
}
|
|
74
|
+
async function refExists(cwd, ref) {
|
|
75
|
+
try {
|
|
76
|
+
await runGit(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function branchHasTaskFile(cwd, ref, tasksRel, n) {
|
|
84
|
+
try {
|
|
85
|
+
const { stdout } = await runGit(cwd, ["ls-tree", "--name-only", `${ref}:${tasksRel}`]);
|
|
86
|
+
return stdout.split("\n").some((f) => taskNumberFromFilename(f) === n);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return false; // no docs/tasks in that tree
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Cross-machine guard for `task ship`: refuse to push `taskBranch` when the remote
|
|
94
|
+
* already has a different generated task branch with the same TASK number (another
|
|
95
|
+
* machine allocated the same id). Fails closed when the remote cannot be listed.
|
|
96
|
+
*/
|
|
97
|
+
export async function assertTaskIdFreeOnRemote(cwd, remote, taskBranch) {
|
|
98
|
+
const n = taskNumberFromBranch(taskBranch);
|
|
99
|
+
if (n === null)
|
|
100
|
+
return { ok: true };
|
|
101
|
+
if ((await gitRemoteUrl(cwd, remote)) === null)
|
|
102
|
+
return { ok: true };
|
|
103
|
+
let stdout;
|
|
104
|
+
try {
|
|
105
|
+
({ stdout } = await runGit(cwd, ["ls-remote", "--heads", remote, "task/*"]));
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
const msg = e instanceof Error ? e.message.split("\n")[0] : String(e);
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
message: `Cannot list task branches on ${remote} (${msg}) — refusing to push without checking for a TASK id collision.`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const clashes = stdout
|
|
115
|
+
.split("\n")
|
|
116
|
+
.map((l) => (l.split("\t")[1] ?? "").replace(/^refs\/heads\//, ""))
|
|
117
|
+
.filter((name) => name.length > 0 && name !== taskBranch && taskNumberFromBranch(name) === n);
|
|
118
|
+
if (clashes.length > 0) {
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
message: `TASK id collision: ${remote} already has ${clashes.join(", ")} for TASK-${String(n).padStart(3, "0")}. Not pushing ${taskBranch}. Renumber this TASK (file + branch) and rerun.`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return { ok: true };
|
|
125
|
+
}
|
|
126
|
+
export async function allocateTaskId(input) {
|
|
127
|
+
const { cwd, tasksDir, remote, integrationBranch } = input;
|
|
128
|
+
const root = await currentWorktreeRoot(cwd);
|
|
129
|
+
// Compare canonical paths (macOS /var → /private/var) to get the repo-relative dir.
|
|
130
|
+
const tasksAbs = await realpath(tasksDir).catch(async () => join(await realpath(dirname(tasksDir)), basename(tasksDir)));
|
|
131
|
+
const tasksRel = relative(root, tasksAbs).split("\\").join("/");
|
|
132
|
+
if (tasksRel.startsWith("..") || tasksRel.length === 0) {
|
|
133
|
+
throw new Error(`TASK directory ${tasksDir} is not inside the worktree ${root}.`);
|
|
134
|
+
}
|
|
135
|
+
// 1. local docs/tasks
|
|
136
|
+
const localFiles = await maxInDir(tasksDir);
|
|
137
|
+
// 2. integration tree
|
|
138
|
+
let integrationTree = 0;
|
|
139
|
+
const treeRef = (await refExists(cwd, `refs/remotes/${remote}/${integrationBranch}`))
|
|
140
|
+
? `${remote}/${integrationBranch}`
|
|
141
|
+
: (await refExists(cwd, `refs/heads/${integrationBranch}`))
|
|
142
|
+
? integrationBranch
|
|
143
|
+
: null;
|
|
144
|
+
if (treeRef !== null) {
|
|
145
|
+
const { stdout } = await runGit(cwd, ["ls-tree", "--name-only", `${treeRef}:${tasksRel}`]).catch(() => ({ stdout: "" }));
|
|
146
|
+
integrationTree = maxOf(stdout.split("\n").map(taskNumberFromFilename));
|
|
147
|
+
}
|
|
148
|
+
// 3. VibeOps task branches (local + freshly fetched remote), with TASK-file evidence
|
|
149
|
+
if (input.fetchRemote !== false && (await gitRemoteUrl(cwd, remote)) !== null) {
|
|
150
|
+
try {
|
|
151
|
+
await runGit(cwd, [
|
|
152
|
+
"fetch",
|
|
153
|
+
"--prune",
|
|
154
|
+
remote,
|
|
155
|
+
`+refs/heads/task/*:refs/remotes/${remote}/task/*`,
|
|
156
|
+
]);
|
|
157
|
+
}
|
|
158
|
+
catch (e) {
|
|
159
|
+
const msg = e instanceof Error ? e.message.split("\n")[0] : String(e);
|
|
160
|
+
throw new TaskIdAllocationError(`Cannot fetch task branches from ${remote} (${msg}) — refusing to pick a TASK id that may collide. Check network / auth and rerun.`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const { stdout: refs } = await runGit(cwd, [
|
|
164
|
+
"for-each-ref",
|
|
165
|
+
"--format=%(refname)",
|
|
166
|
+
"refs/heads/task/",
|
|
167
|
+
`refs/remotes/${remote}/task/`,
|
|
168
|
+
]);
|
|
169
|
+
let branches = 0;
|
|
170
|
+
for (const ref of refs.split("\n").filter((r) => r.length > 0)) {
|
|
171
|
+
const n = taskNumberFromBranch(ref);
|
|
172
|
+
if (n === null || n <= branches)
|
|
173
|
+
continue;
|
|
174
|
+
if (await branchHasTaskFile(cwd, ref, tasksRel, n))
|
|
175
|
+
branches = n;
|
|
176
|
+
}
|
|
177
|
+
// 4. every worktree's docs/tasks
|
|
178
|
+
let worktrees = 0;
|
|
179
|
+
for (const wt of await listWorktrees(cwd)) {
|
|
180
|
+
if (wt.bare)
|
|
181
|
+
continue;
|
|
182
|
+
worktrees = Math.max(worktrees, await maxInDir(join(wt.path, tasksRel)));
|
|
183
|
+
}
|
|
184
|
+
const sources = {
|
|
185
|
+
localFiles,
|
|
186
|
+
integrationTree,
|
|
187
|
+
branches,
|
|
188
|
+
worktrees,
|
|
189
|
+
};
|
|
190
|
+
const max = Math.max(...Object.values(sources));
|
|
191
|
+
return { next: max + 1, max, sources };
|
|
192
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { linkSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { readTextOrNull } from "./filesystem.js";
|
|
6
|
+
import { isGitRepository, runGit } from "./git.js";
|
|
7
|
+
import { dim, log } from "./logger.js";
|
|
8
|
+
import { VIBEOPS_CONFIG_FILE } from "./paths.js";
|
|
9
|
+
/**
|
|
10
|
+
* Repository-wide lock for VibeOps operations that allocate TASK ids, write TASK
|
|
11
|
+
* files, create / switch / delete task branches, or stash (D-006). All worktrees
|
|
12
|
+
* of a repository share one git common dir, so the lock file lives there:
|
|
13
|
+
* `<git-common-dir>/vibeops-task.lock`, created atomically with its content
|
|
14
|
+
* (temp file + `link`, EEXIST when held).
|
|
15
|
+
*
|
|
16
|
+
* Stale lock: holder on THIS host whose pid is no longer alive, or a lock older
|
|
17
|
+
* than `lock.staleSeconds` on ANY host (covers pid reuse on this host and
|
|
18
|
+
* unverifiable pids on other hosts). A start time in the future (clock skew) is
|
|
19
|
+
* respected, not stale. Removal (release / stale takeover) is guarded by a
|
|
20
|
+
* breaker file — see {@link withBreakerSync}.
|
|
21
|
+
*/
|
|
22
|
+
export const LOCK_FILE_NAME = "vibeops-task.lock";
|
|
23
|
+
export const TASK_LOCK_DEFAULTS = { waitSeconds: 60, staleSeconds: 600 };
|
|
24
|
+
export const TASK_LOCK_LIMITS = {
|
|
25
|
+
waitSeconds: { min: 0, max: 600 },
|
|
26
|
+
staleSeconds: { min: 60, max: 86_400 },
|
|
27
|
+
};
|
|
28
|
+
export class TaskLockConfigError extends Error {
|
|
29
|
+
constructor(message) {
|
|
30
|
+
super(`${VIBEOPS_CONFIG_FILE} lock: ${message}`);
|
|
31
|
+
this.name = "TaskLockConfigError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export class TaskLockHeldError extends Error {
|
|
35
|
+
constructor(message) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = "TaskLockHeldError";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Validate `.vibeops.json` `lock` (fail closed). `undefined` → defaults. */
|
|
41
|
+
export function parseTaskLockConfig(raw) {
|
|
42
|
+
if (raw === undefined)
|
|
43
|
+
return TASK_LOCK_DEFAULTS;
|
|
44
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
45
|
+
throw new TaskLockConfigError("must be an object.");
|
|
46
|
+
}
|
|
47
|
+
const o = raw;
|
|
48
|
+
const allowed = Object.keys(TASK_LOCK_LIMITS);
|
|
49
|
+
const unknown = Object.keys(o).filter((k) => !allowed.includes(k));
|
|
50
|
+
if (unknown.length > 0) {
|
|
51
|
+
throw new TaskLockConfigError(`unknown key ${unknown.map((k) => `\`${k}\``).join(", ")} (allowed: ${allowed.join(", ")}).`);
|
|
52
|
+
}
|
|
53
|
+
const out = { ...TASK_LOCK_DEFAULTS };
|
|
54
|
+
for (const key of allowed) {
|
|
55
|
+
const v = o[key];
|
|
56
|
+
if (v === undefined)
|
|
57
|
+
continue;
|
|
58
|
+
const { min, max } = TASK_LOCK_LIMITS[key];
|
|
59
|
+
if (typeof v !== "number" || !Number.isInteger(v) || v < min || v > max) {
|
|
60
|
+
throw new TaskLockConfigError(`\`${key}\` must be an integer between ${min} and ${max}.`);
|
|
61
|
+
}
|
|
62
|
+
out[key] = v;
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
export async function readTaskLockConfig(root) {
|
|
67
|
+
const text = await readTextOrNull(join(root, VIBEOPS_CONFIG_FILE));
|
|
68
|
+
if (text === null)
|
|
69
|
+
return TASK_LOCK_DEFAULTS;
|
|
70
|
+
let parsed;
|
|
71
|
+
try {
|
|
72
|
+
parsed = JSON.parse(text);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
throw new TaskLockConfigError("file is not valid JSON.");
|
|
76
|
+
}
|
|
77
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
78
|
+
return TASK_LOCK_DEFAULTS;
|
|
79
|
+
}
|
|
80
|
+
return parseTaskLockConfig(parsed.lock);
|
|
81
|
+
}
|
|
82
|
+
export async function taskLockPath(cwd) {
|
|
83
|
+
const { stdout } = await runGit(cwd, ["rev-parse", "--git-common-dir"]);
|
|
84
|
+
const dir = stdout.trim();
|
|
85
|
+
return join(isAbsolute(dir) ? dir : resolve(cwd, dir), LOCK_FILE_NAME);
|
|
86
|
+
}
|
|
87
|
+
function pidAlive(pid) {
|
|
88
|
+
try {
|
|
89
|
+
process.kill(pid, 0);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
return e.code === "EPERM";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function readLockSync(path) {
|
|
97
|
+
try {
|
|
98
|
+
const v = JSON.parse(readFileSync(path, "utf8"));
|
|
99
|
+
// pid + host are enough to judge staleness; token / startedAt may be missing
|
|
100
|
+
// in a foreign or older lock file.
|
|
101
|
+
return typeof v.pid === "number" && typeof v.host === "string" ? v : null;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function inodeOf(path) {
|
|
108
|
+
try {
|
|
109
|
+
return statSync(path).ino;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Why the existing lock is stale, or null when it must be respected. */
|
|
116
|
+
export function staleReason(info, cfg, now = Date.now(), mtimeMs = null) {
|
|
117
|
+
if (info === null) {
|
|
118
|
+
// Unparseable (foreign / truncated) file: stale only by age.
|
|
119
|
+
return mtimeMs !== null && (now - mtimeMs) / 1000 > cfg.staleSeconds
|
|
120
|
+
? `unreadable and older than ${cfg.staleSeconds}s`
|
|
121
|
+
: null;
|
|
122
|
+
}
|
|
123
|
+
if (info.host === hostname() && !pidAlive(info.pid)) {
|
|
124
|
+
return `pid ${info.pid} is not running`;
|
|
125
|
+
}
|
|
126
|
+
const age = (now - Date.parse(info.startedAt)) / 1000;
|
|
127
|
+
return Number.isFinite(age) && age > cfg.staleSeconds
|
|
128
|
+
? `older than ${cfg.staleSeconds}s (pid ${info.pid} on ${info.host})`
|
|
129
|
+
: null;
|
|
130
|
+
}
|
|
131
|
+
function describe(info, path) {
|
|
132
|
+
if (info === null)
|
|
133
|
+
return `lock file ${path}`;
|
|
134
|
+
return `pid ${info.pid} on ${info.host} since ${info.startedAt} (${info.operation} in ${info.cwd})`;
|
|
135
|
+
}
|
|
136
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
137
|
+
const sleepSync = (ms) => {
|
|
138
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
139
|
+
};
|
|
140
|
+
/**
|
|
141
|
+
* Create `path` atomically WITH its content: write a private temp file, then
|
|
142
|
+
* `link` it into place (fails with EEXIST if `path` exists). Readers never see a
|
|
143
|
+
* half-written lock. Returns false when the lock already exists.
|
|
144
|
+
*/
|
|
145
|
+
function createExclusiveSync(path, content) {
|
|
146
|
+
const tmp = `${path}.${randomUUID()}.tmp`;
|
|
147
|
+
writeFileSync(tmp, content, { flag: "wx" });
|
|
148
|
+
try {
|
|
149
|
+
linkSync(tmp, path);
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
if (e.code === "EEXIST")
|
|
154
|
+
return false;
|
|
155
|
+
throw e;
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
unlinkSync(tmp);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Internal, not configurable: a breaker is held for microseconds (re-check +
|
|
163
|
+
* unlink), so these only matter after a crash inside that window.
|
|
164
|
+
* - BREAKER_STALE_MS: a breaker older than this was left by a crash.
|
|
165
|
+
* - BREAKER_WAIT_MS: upper bound on waiting for the breaker. A stale TAKEOVER
|
|
166
|
+
* waits min(this, lock.waitSeconds) — the user's wait budget. RELEASE always
|
|
167
|
+
* uses this full bound: giving up early would leave the lock behind.
|
|
168
|
+
*/
|
|
169
|
+
const BREAKER_STALE_MS = 30_000;
|
|
170
|
+
const BREAKER_WAIT_MS = 60_000;
|
|
171
|
+
/**
|
|
172
|
+
* Every REMOVAL of the lock file (release, stale takeover) happens while holding
|
|
173
|
+
* the breaker `<lock>.break`. Creation needs the lock to be absent (link), so
|
|
174
|
+
* while the breaker is held the lock file cannot change identity: "verify
|
|
175
|
+
* identity, then unlink" is atomic with respect to all other VibeOps processes.
|
|
176
|
+
*/
|
|
177
|
+
function withBreakerSync(lockPath, fn, waitMs = BREAKER_WAIT_MS) {
|
|
178
|
+
const breaker = `${lockPath}.break`;
|
|
179
|
+
const token = randomUUID();
|
|
180
|
+
const deadline = Date.now() + Math.min(BREAKER_WAIT_MS, waitMs);
|
|
181
|
+
let loggedWait = false;
|
|
182
|
+
for (;;) {
|
|
183
|
+
if (createExclusiveSync(breaker, `${JSON.stringify({ token, pid: process.pid, host: hostname() })}\n`)) {
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
let st = null;
|
|
187
|
+
try {
|
|
188
|
+
st = statSync(breaker);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
continue; // released meanwhile
|
|
192
|
+
}
|
|
193
|
+
if (Date.now() - st.mtimeMs > BREAKER_STALE_MS) {
|
|
194
|
+
// Crash inside a breaker section: move it aside, delete only if it is
|
|
195
|
+
// still the same file (inode) we judged stale.
|
|
196
|
+
const aside = `${breaker}.stale-${randomUUID()}`;
|
|
197
|
+
try {
|
|
198
|
+
renameSync(breaker, aside);
|
|
199
|
+
if (statSync(aside).ino === st.ino)
|
|
200
|
+
unlinkSync(aside);
|
|
201
|
+
else {
|
|
202
|
+
try {
|
|
203
|
+
linkSync(aside, breaker);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// a fresh breaker exists again; ours is not needed
|
|
207
|
+
}
|
|
208
|
+
unlinkSync(aside);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
// someone else handled it
|
|
213
|
+
}
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (Date.now() >= deadline) {
|
|
217
|
+
throw new TaskLockHeldError(`VibeOps lock breaker ${breaker} is held; retry, or delete it if no vibeops process is running.`);
|
|
218
|
+
}
|
|
219
|
+
if (!loggedWait) {
|
|
220
|
+
log.info(dim(`Waiting for VibeOps lock breaker ${breaker}…`));
|
|
221
|
+
loggedWait = true;
|
|
222
|
+
}
|
|
223
|
+
sleepSync(2);
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
return fn();
|
|
227
|
+
}
|
|
228
|
+
finally {
|
|
229
|
+
try {
|
|
230
|
+
const cur = JSON.parse(readFileSync(breaker, "utf8"));
|
|
231
|
+
if (cur.token === token)
|
|
232
|
+
unlinkSync(breaker);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// already gone
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/** Unlink the lock only if it is still the file identified by (inode, token). */
|
|
240
|
+
function removeLockIfSame(path, ino, token, breakerWaitMs) {
|
|
241
|
+
return withBreakerSync(path, () => {
|
|
242
|
+
if (inodeOf(path) !== ino)
|
|
243
|
+
return false;
|
|
244
|
+
const cur = readLockSync(path);
|
|
245
|
+
if ((typeof cur?.token === "string" ? cur.token : null) !== token)
|
|
246
|
+
return false;
|
|
247
|
+
unlinkSync(path);
|
|
248
|
+
return true;
|
|
249
|
+
}, breakerWaitMs);
|
|
250
|
+
}
|
|
251
|
+
/** Acquire the repository task lock, waiting up to `waitSeconds` for another holder. */
|
|
252
|
+
export async function acquireTaskLock(cwd, operation, cfg = TASK_LOCK_DEFAULTS) {
|
|
253
|
+
const path = await taskLockPath(cwd);
|
|
254
|
+
const info = {
|
|
255
|
+
token: randomUUID(),
|
|
256
|
+
pid: process.pid,
|
|
257
|
+
host: hostname(),
|
|
258
|
+
startedAt: new Date().toISOString(),
|
|
259
|
+
operation,
|
|
260
|
+
cwd: resolve(cwd),
|
|
261
|
+
};
|
|
262
|
+
const content = `${JSON.stringify(info)}\n`;
|
|
263
|
+
const deadline = Date.now() + cfg.waitSeconds * 1000;
|
|
264
|
+
let loggedWait = false;
|
|
265
|
+
for (;;) {
|
|
266
|
+
if (createExclusiveSync(path, content))
|
|
267
|
+
break;
|
|
268
|
+
let st;
|
|
269
|
+
try {
|
|
270
|
+
st = statSync(path);
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
continue; // released meanwhile
|
|
274
|
+
}
|
|
275
|
+
const holder = readLockSync(path);
|
|
276
|
+
const stale = staleReason(holder, cfg, Date.now(), st.mtimeMs);
|
|
277
|
+
if (stale !== null) {
|
|
278
|
+
const judgedToken = typeof holder?.token === "string" ? holder.token : null;
|
|
279
|
+
// Takeover honours the user's wait budget; release (below) does not.
|
|
280
|
+
if (removeLockIfSame(path, st.ino, judgedToken, cfg.waitSeconds * 1000)) {
|
|
281
|
+
log.warn(`Removed stale VibeOps lock (${stale}): ${path}`);
|
|
282
|
+
}
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (Date.now() >= deadline) {
|
|
286
|
+
throw new TaskLockHeldError(`Another VibeOps task operation holds the repository lock: ${describe(holder, path)}. ` +
|
|
287
|
+
`Wait for it to finish and rerun. If that process is gone, delete ${path}.`);
|
|
288
|
+
}
|
|
289
|
+
if (!loggedWait) {
|
|
290
|
+
log.info(dim(`Waiting for VibeOps lock held by ${describe(holder, path)}…`));
|
|
291
|
+
loggedWait = true;
|
|
292
|
+
}
|
|
293
|
+
await sleep(Math.min(50, Math.max(0, deadline - Date.now())));
|
|
294
|
+
}
|
|
295
|
+
const ino = inodeOf(path);
|
|
296
|
+
let released = false;
|
|
297
|
+
const releaseSync = () => {
|
|
298
|
+
if (released)
|
|
299
|
+
return;
|
|
300
|
+
released = true;
|
|
301
|
+
if (ino !== null)
|
|
302
|
+
removeLockIfSame(path, ino, info.token);
|
|
303
|
+
};
|
|
304
|
+
process.once("exit", releaseSync);
|
|
305
|
+
return {
|
|
306
|
+
path,
|
|
307
|
+
async release() {
|
|
308
|
+
process.removeListener("exit", releaseSync);
|
|
309
|
+
releaseSync();
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
/** Run `fn` while holding the repository task lock. */
|
|
314
|
+
export async function withTaskLock(cwd, operation, fn, cfg) {
|
|
315
|
+
const lock = await acquireTaskLock(cwd, operation, cfg ?? (await readTaskLockConfig(cwd)));
|
|
316
|
+
try {
|
|
317
|
+
return await fn();
|
|
318
|
+
}
|
|
319
|
+
finally {
|
|
320
|
+
await lock.release();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Command wrapper: run `fn` under the repository task lock, reporting lock /
|
|
325
|
+
* config errors with exit code 1. Outside a git repository `fn` runs unlocked
|
|
326
|
+
* (it reports that error itself).
|
|
327
|
+
*/
|
|
328
|
+
export async function runUnderTaskLock(cwd, operation, fn) {
|
|
329
|
+
if (!(await isGitRepository(cwd))) {
|
|
330
|
+
await fn();
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
let cfg;
|
|
334
|
+
try {
|
|
335
|
+
cfg = await readTaskLockConfig(cwd);
|
|
336
|
+
}
|
|
337
|
+
catch (e) {
|
|
338
|
+
if (e instanceof TaskLockConfigError) {
|
|
339
|
+
log.error(e.message);
|
|
340
|
+
process.exitCode = 1;
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
throw e;
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
await withTaskLock(cwd, operation, fn, cfg);
|
|
347
|
+
}
|
|
348
|
+
catch (e) {
|
|
349
|
+
if (e instanceof TaskLockHeldError) {
|
|
350
|
+
log.error(e.message);
|
|
351
|
+
process.exitCode = 1;
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
throw e;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
@@ -11,6 +11,7 @@ import { applyTaskShipMemory, llmCompleteTaskReship, writeTaskResultSections, }
|
|
|
11
11
|
import { assertMergeRequestNotOpen, ensureTaskBranchForReship, integrateIntegrationIntoTaskBranch, loadGitConfigForReship, prepareGitContextForReship, refreshGitContextBaseCommit, resolveReshipTarget, } from "./task-reship.js";
|
|
12
12
|
import { hasNonEmptySection, parseTaskFilename, readGitContext } from "./task.js";
|
|
13
13
|
import { prRefLabel } from "./task-ship-state.js";
|
|
14
|
+
import { runUnderTaskLock } from "./task-lock.js";
|
|
14
15
|
async function commitReshipMetadata(opts) {
|
|
15
16
|
if (opts.dryRun) {
|
|
16
17
|
log.info(dim(" would commit new-cycle metadata (Status stays Shipped)"));
|
|
@@ -56,7 +57,7 @@ export async function runPostMergeNewCycle(taskRef, options = {}) {
|
|
|
56
57
|
process.exitCode = 1;
|
|
57
58
|
return;
|
|
58
59
|
}
|
|
59
|
-
const
|
|
60
|
+
const ensureBranch = () => ensureTaskBranchForReship({
|
|
60
61
|
cwd,
|
|
61
62
|
taskFile,
|
|
62
63
|
gitCtx: initialCtx,
|
|
@@ -65,6 +66,16 @@ export async function runPostMergeNewCycle(taskRef, options = {}) {
|
|
|
65
66
|
recreateBranch: options.recreateBranch === true,
|
|
66
67
|
dryRun,
|
|
67
68
|
});
|
|
69
|
+
let branchOk = false;
|
|
70
|
+
if (dryRun) {
|
|
71
|
+
branchOk = await ensureBranch();
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
// D-006: branch switch / recreate / stash are serialized across worktrees.
|
|
75
|
+
await runUnderTaskLock(cwd, "task ship --new-cycle", async () => {
|
|
76
|
+
branchOk = await ensureBranch();
|
|
77
|
+
});
|
|
78
|
+
}
|
|
68
79
|
if (!branchOk) {
|
|
69
80
|
process.exitCode = 1;
|
|
70
81
|
return;
|
package/dist/lib/task-push-pr.js
CHANGED
|
@@ -8,6 +8,7 @@ import { dim, log } from "./logger.js";
|
|
|
8
8
|
import { createMergeRequest, findMergeRequestByBranches, probeMergeRequestCli, } from "./pr-create.js";
|
|
9
9
|
import { fallbackTaskPr, generateTaskPrWithLlm } from "./task-pr-llm.js";
|
|
10
10
|
import { readGitContext, readTaskFile } from "./task.js";
|
|
11
|
+
import { assertTaskIdFreeOnRemote } from "./task-id-allocation.js";
|
|
11
12
|
function mergeRequestAlreadyExistsMessage(msg) {
|
|
12
13
|
const lower = msg.toLowerCase();
|
|
13
14
|
return (lower.includes("already exists") ||
|
|
@@ -66,6 +67,12 @@ export async function finishTaskWithPullRequest(opts) {
|
|
|
66
67
|
if (ahead === 0) {
|
|
67
68
|
log.warn("No commits ahead of base — pushing branch anyway.");
|
|
68
69
|
}
|
|
70
|
+
// D-006: another machine may have pushed a task branch with the same TASK id.
|
|
71
|
+
const idFree = await assertTaskIdFreeOnRemote(opts.cwd, gitCfg.remote, gitCtx.taskBranch);
|
|
72
|
+
if (!idFree.ok) {
|
|
73
|
+
log.error(idFree.message);
|
|
74
|
+
return { ok: false };
|
|
75
|
+
}
|
|
69
76
|
try {
|
|
70
77
|
await gitPush(opts.cwd, gitCfg.remote, gitCtx.taskBranch, true);
|
|
71
78
|
log.ok(`Pushed ${gitCtx.taskBranch} → ${gitCfg.remote}`);
|
package/dist/lib/task-reship.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { GitConfigError, requireGitConfig } from "./git-config.js";
|
|
3
|
-
import { gitBranchExists, gitCheckout, gitCheckoutNewBranch, gitDeleteBranch, gitFetchRemote, gitHeadCommit, gitMergeRemoteBranch, gitMergeInProgress,
|
|
3
|
+
import { gitBranchExists, gitCheckout, gitCheckoutNewBranch, gitDeleteBranch, gitFetchRemote, gitHeadCommit, gitMergeRemoteBranch, gitMergeInProgress, gitFetch, gitRemoteBranchExists, gitRemoteUrl, gitRevParse, gitSwitchToBranch, listUnmergedRelPaths, readGitInfo, } from "./git.js";
|
|
4
|
+
import { fastForwardLocalIntegration, leaveTaskBranch, logLocalIntegrationUpdate, resolveIntegrationBaseRef, } from "./git-worktree.js";
|
|
4
5
|
import { dim, log } from "./logger.js";
|
|
5
6
|
import { findMergeRequestByBranches } from "./pr-create.js";
|
|
6
7
|
import { normalizeTaskRef } from "./resolve-task.js";
|
|
@@ -63,9 +64,10 @@ async function removeLocalTaskBranch(cwd, taskBranch, integrationBranch, remote)
|
|
|
63
64
|
return true;
|
|
64
65
|
const git = await readGitInfo(cwd);
|
|
65
66
|
if (git.branch === taskBranch) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
// Worktree-safe (D-006): detach when another worktree owns the integration branch.
|
|
68
|
+
const left = await leaveTaskBranch(cwd, remote, integrationBranch);
|
|
69
|
+
if (!left.ok) {
|
|
70
|
+
log.error(left.message);
|
|
69
71
|
return false;
|
|
70
72
|
}
|
|
71
73
|
}
|
|
@@ -83,14 +85,26 @@ async function removeLocalTaskBranch(cwd, taskBranch, integrationBranch, remote)
|
|
|
83
85
|
async function recreateTaskBranchFromIntegration(opts) {
|
|
84
86
|
const cwd = resolve(opts.cwd);
|
|
85
87
|
const { taskBranch } = opts.gitCtx;
|
|
86
|
-
|
|
87
|
-
|
|
88
|
+
// Worktree-safe (D-006): start from <remote>/<integration>; never check out
|
|
89
|
+
// the integration branch (another worktree may own it).
|
|
90
|
+
if ((await gitRemoteUrl(cwd, opts.remote)) !== null) {
|
|
91
|
+
try {
|
|
92
|
+
await gitFetch(cwd, opts.remote, opts.integrationBranch);
|
|
93
|
+
}
|
|
94
|
+
catch (e) {
|
|
95
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
96
|
+
log.warn(`git fetch failed (${msg}). Continuing with local refs.`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const baseRef = await resolveIntegrationBaseRef(cwd, opts.remote, opts.integrationBranch);
|
|
100
|
+
if (baseRef === null) {
|
|
88
101
|
log.error(`Integration branch "${opts.integrationBranch}" not found.`);
|
|
89
102
|
return false;
|
|
90
103
|
}
|
|
91
104
|
if (await gitRemoteBranchExists(cwd, opts.remote, opts.integrationBranch)) {
|
|
92
105
|
try {
|
|
93
|
-
await
|
|
106
|
+
const update = await fastForwardLocalIntegration(cwd, opts.remote, opts.integrationBranch);
|
|
107
|
+
logLocalIntegrationUpdate(update, opts.remote, opts.integrationBranch);
|
|
94
108
|
}
|
|
95
109
|
catch (e) {
|
|
96
110
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -98,17 +112,18 @@ async function recreateTaskBranchFromIntegration(opts) {
|
|
|
98
112
|
return false;
|
|
99
113
|
}
|
|
100
114
|
}
|
|
101
|
-
|
|
102
|
-
|
|
115
|
+
// Full SHA: unambiguous base for later diff ranges.
|
|
116
|
+
const head = (await gitRevParse(cwd, `${baseRef}^{commit}`)) ?? "";
|
|
117
|
+
if (head.length === 0) {
|
|
103
118
|
log.error("Integration branch has no commits.");
|
|
104
119
|
return false;
|
|
105
120
|
}
|
|
106
121
|
try {
|
|
107
|
-
await gitCheckoutNewBranch(cwd, taskBranch,
|
|
122
|
+
await gitCheckoutNewBranch(cwd, taskBranch, baseRef, { noTrack: true });
|
|
108
123
|
}
|
|
109
124
|
catch (e) {
|
|
110
125
|
const msg = e instanceof Error ? e.message : String(e);
|
|
111
|
-
log.error(`Could not create ${taskBranch} from ${
|
|
126
|
+
log.error(`Could not create ${taskBranch} from ${baseRef} with uncommitted changes.`);
|
|
112
127
|
if (msg.length > 0)
|
|
113
128
|
log.info(dim(msg));
|
|
114
129
|
return false;
|
|
@@ -118,7 +133,7 @@ async function recreateTaskBranchFromIntegration(opts) {
|
|
|
118
133
|
baseBranch: opts.integrationBranch,
|
|
119
134
|
baseCommit: head,
|
|
120
135
|
});
|
|
121
|
-
log.ok(`Created ${taskBranch} from ${
|
|
136
|
+
log.ok(`Created ${taskBranch} from ${baseRef}`);
|
|
122
137
|
return true;
|
|
123
138
|
}
|
|
124
139
|
/** Checkout or recreate the task branch without changing TASK Status. */
|