@tea-agent/loop-agent 0.28.2-beta.1 → 0.28.3
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/AGENTS.md +1 -1
- package/CHANGELOG.md +37 -0
- package/README.md +11 -1
- package/dist/cli/command-definitions.js +2 -1
- package/dist/commands/client-recovery.js +111 -8
- package/dist/commands/dag-init-hybrid.js +1 -1
- package/dist/commands/init-upgrade.js +2479 -0
- package/dist/commands/init.js +120 -9
- package/dist/governance/manifest-types.js +65 -0
- package/dist/shared/operator/capabilities.js +350 -2
- package/dist/task/worktree.js +256 -39
- package/dist/worker/cli.js +22 -12
- package/dist/worker/console/chat/workspace-landing.js +16 -6
- package/dist/worker/console/observe-health-match.js +2 -0
- package/dist/worker/console/observe-link.js +4 -0
- package/dist/worker/console/operator-actions.js +183 -4
- package/dist/worker/console/operator-selection.js +13 -0
- package/dist/worker/console/static/assets/index-BfRgtLF4.js +29 -0
- package/dist/worker/console/static/index.html +1 -1
- package/dist/worker/observe/health.js +1 -0
- package/dist/worker/observe/night-jobs.js +104 -0
- package/dist/worker/observe/routes.js +48 -0
- package/dist/worker/observe/static/app.js +3 -0
- package/dist/worker/observe/static/constants.js +1 -0
- package/dist/worker/observe/static/index.html +47 -0
- package/dist/worker/observe/static/router.js +10 -0
- package/dist/worker/observe/static/shell-chrome.js +1 -0
- package/dist/worker/observe/static/views/night.js +201 -0
- package/dist/worker/report/morning-report.js +56 -16
- package/dist/worker/run-task/execute-prepared-task.js +153 -0
- package/dist/worker/runner/single-task-attempt.js +147 -0
- package/dist/worker/scheduler/admission.js +538 -0
- package/dist/worker/scheduler/auto-followup.js +99 -0
- package/dist/worker/scheduler/cli.js +539 -0
- package/dist/worker/scheduler/dispatcher.js +503 -0
- package/dist/worker/scheduler/doctor.js +346 -0
- package/dist/worker/scheduler/evidence.js +170 -0
- package/dist/worker/scheduler/git-base.js +90 -0
- package/dist/worker/scheduler/index.js +23 -0
- package/dist/worker/scheduler/lease.js +114 -0
- package/dist/worker/scheduler/lifecycle.js +348 -0
- package/dist/worker/scheduler/lock.js +80 -0
- package/dist/worker/scheduler/morning-window.js +161 -0
- package/dist/worker/scheduler/night-git-finalizer.js +88 -0
- package/dist/worker/scheduler/night-harvest.js +412 -0
- package/dist/worker/scheduler/paths.js +84 -0
- package/dist/worker/scheduler/prepared-attempt-recovery.js +471 -0
- package/dist/worker/scheduler/recovery.js +277 -0
- package/dist/worker/scheduler/reservation.js +146 -0
- package/dist/worker/scheduler/retry.js +199 -0
- package/dist/worker/scheduler/scheduler-loop.js +272 -0
- package/dist/worker/scheduler/store.js +275 -0
- package/dist/worker/scheduler/traceability.js +54 -0
- package/dist/worker/scheduler/trigger.js +258 -0
- package/dist/worker/scheduler/types.js +369 -0
- package/dist/worker/scheduler/workspace-adapter.js +91 -0
- package/dist/workflows/dag/frontend-implementation-contract.js +2 -102
- package/docs/architecture/runtime-boundaries.md +9 -0
- package/docs/init-surface.manifest.json +9 -2
- package/docs/templates/harness.schema.json +107 -0
- package/docs/templates/init-managed-agents.md +18 -8
- package/harness.json +22 -0
- package/package.json +1 -1
- package/skills/loop-agent/SKILL.md +28 -36
- package/skills/loop-agent/references/command-reference.md +40 -16
- package/skills/loop-agent/references/hybrid-dag.md +1 -1
- package/dist/worker/console/static/assets/index-CNO7n6qB.js +0 -29
package/dist/task/worktree.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { access, appendFile, mkdir, readFile, realpath, writeFile, } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
const OWNERSHIP_FILENAME = ".loop-agent-worktree-ownership.json";
|
|
4
6
|
/**
|
|
5
7
|
* Resolve the worktree root directory from a harness manifest.
|
|
6
8
|
* Falls back to `<repoRoot>/.worktrees` when not configured.
|
|
7
9
|
*/
|
|
8
10
|
export function resolveWorktreeRoot(repoRoot, manifest) {
|
|
9
|
-
const relativePath = manifest?.worktree?.rootRelativePath ??
|
|
11
|
+
const relativePath = manifest?.worktree?.rootRelativePath ?? ".worktrees";
|
|
10
12
|
return path.resolve(repoRoot, relativePath);
|
|
11
13
|
}
|
|
12
14
|
/**
|
|
@@ -37,66 +39,215 @@ export async function createWorktree(repoRoot, manifest, opts) {
|
|
|
37
39
|
const resolvedRoot = await resolveRepoRoot(repoRoot);
|
|
38
40
|
const worktreePath = resolveWorktreePath(resolvedRoot, manifest, opts.taskId);
|
|
39
41
|
const branch = opts.branch ?? `task/${opts.taskId}`;
|
|
40
|
-
const base = opts.base ??
|
|
42
|
+
const base = opts.base ?? "HEAD";
|
|
41
43
|
// Check if worktree path already exists
|
|
42
44
|
try {
|
|
43
45
|
await access(worktreePath);
|
|
44
46
|
throw new Error(`worktree path already exists: ${worktreePath}`);
|
|
45
47
|
}
|
|
46
48
|
catch (error) {
|
|
47
|
-
if (error.code !==
|
|
49
|
+
if (error.code !== "ENOENT") {
|
|
48
50
|
throw error;
|
|
49
51
|
}
|
|
50
52
|
}
|
|
51
53
|
// Ensure parent directory exists
|
|
52
54
|
await mkdir(path.dirname(worktreePath), { recursive: true });
|
|
53
55
|
// Build git worktree add arguments
|
|
54
|
-
const addArgs = [
|
|
56
|
+
const addArgs = ["worktree", "add", worktreePath];
|
|
55
57
|
// Check if branch already exists; if so, don't pass -b
|
|
56
58
|
const branchExists = await gitBranchExists(resolvedRoot, branch);
|
|
57
59
|
if (!branchExists) {
|
|
58
|
-
addArgs.push(
|
|
60
|
+
addArgs.push("-b", branch);
|
|
59
61
|
}
|
|
60
62
|
addArgs.push(base);
|
|
61
63
|
await runGit(resolvedRoot, addArgs);
|
|
62
64
|
return { path: worktreePath, branch, base };
|
|
63
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Idempotent ensure for a schedule-owned worktree.
|
|
68
|
+
* Validates path ↔ branch ↔ baseCommit and ownership token; never reuses an
|
|
69
|
+
* unowned path/branch with the same names.
|
|
70
|
+
*/
|
|
71
|
+
export async function ensureOwnedWorktree(repoRoot, options) {
|
|
72
|
+
const resolvedRoot = await resolveRepoRoot(repoRoot);
|
|
73
|
+
const worktreePath = resolveAgainstCanonicalRepoRoot(repoRoot, resolvedRoot, options.worktreePath);
|
|
74
|
+
assertOwnedWorktreePath(resolvedRoot, worktreePath);
|
|
75
|
+
const ownershipPath = path.join(worktreePath, OWNERSHIP_FILENAME);
|
|
76
|
+
const now = options.now ?? new Date();
|
|
77
|
+
let pathExists = false;
|
|
78
|
+
try {
|
|
79
|
+
await access(worktreePath);
|
|
80
|
+
pathExists = true;
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (error.code !== "ENOENT")
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
if (pathExists) {
|
|
87
|
+
const ownership = await readOwnershipRecord(ownershipPath);
|
|
88
|
+
if (!ownership) {
|
|
89
|
+
throw new Error(`worktree path exists without ownership record: ${worktreePath}`);
|
|
90
|
+
}
|
|
91
|
+
if (ownership.scheduleId !== options.scheduleId) {
|
|
92
|
+
throw new Error(`worktree ownership schedule mismatch: expected ${options.scheduleId}, found ${ownership.scheduleId}`);
|
|
93
|
+
}
|
|
94
|
+
if (options.ownershipToken &&
|
|
95
|
+
ownership.ownershipToken !== options.ownershipToken) {
|
|
96
|
+
throw new Error(`worktree ownership token mismatch at ${worktreePath}`);
|
|
97
|
+
}
|
|
98
|
+
if (ownership.branch !== options.branch ||
|
|
99
|
+
ownership.baseCommit !== options.baseCommit ||
|
|
100
|
+
ownership.baseBranch !== options.baseBranch) {
|
|
101
|
+
throw new Error(`worktree ownership triple mismatch at ${worktreePath}: expected branch=${options.branch} base=${options.baseBranch}@${options.baseCommit}`);
|
|
102
|
+
}
|
|
103
|
+
const entries = await listWorktrees(resolvedRoot);
|
|
104
|
+
const registered = entries.find((entry) => path.normalize(entry.path) === path.normalize(worktreePath));
|
|
105
|
+
if (!registered) {
|
|
106
|
+
throw new Error(`worktree path exists but is not registered in git worktree list: ${worktreePath}`);
|
|
107
|
+
}
|
|
108
|
+
if (registered.branch && registered.branch !== options.branch) {
|
|
109
|
+
throw new Error(`worktree registry branch mismatch: expected ${options.branch}, found ${registered.branch}`);
|
|
110
|
+
}
|
|
111
|
+
const head = (await runGit(worktreePath, ["rev-parse", "HEAD"])).trim();
|
|
112
|
+
if (head !== options.baseCommit) {
|
|
113
|
+
throw new Error(`worktree HEAD drift at ${worktreePath}: expected ${options.baseCommit}, found ${head}`);
|
|
114
|
+
}
|
|
115
|
+
await ensureOwnershipFileIgnored(worktreePath);
|
|
116
|
+
return {
|
|
117
|
+
path: worktreePath,
|
|
118
|
+
branch: options.branch,
|
|
119
|
+
baseBranch: options.baseBranch,
|
|
120
|
+
baseCommit: options.baseCommit,
|
|
121
|
+
ownershipToken: ownership.ownershipToken,
|
|
122
|
+
created: false,
|
|
123
|
+
reused: true,
|
|
124
|
+
head,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
// Create path: branch must not already exist without our ownership.
|
|
128
|
+
const branchExists = await gitBranchExists(resolvedRoot, options.branch);
|
|
129
|
+
if (branchExists) {
|
|
130
|
+
throw new Error(`branch already exists without owned worktree: ${options.branch}`);
|
|
131
|
+
}
|
|
132
|
+
await mkdir(path.dirname(worktreePath), { recursive: true });
|
|
133
|
+
await runGit(resolvedRoot, [
|
|
134
|
+
"worktree",
|
|
135
|
+
"add",
|
|
136
|
+
worktreePath,
|
|
137
|
+
"-b",
|
|
138
|
+
options.branch,
|
|
139
|
+
options.baseCommit,
|
|
140
|
+
]);
|
|
141
|
+
const ownershipToken = options.ownershipToken ??
|
|
142
|
+
`sha256:${createHash("sha256")
|
|
143
|
+
.update(`${options.scheduleId}\0${options.branch}\0${options.baseCommit}\0${randomBytes(8).toString("hex")}`)
|
|
144
|
+
.digest("hex")}`;
|
|
145
|
+
const ownership = {
|
|
146
|
+
schemaVersion: 1,
|
|
147
|
+
scheduleId: options.scheduleId,
|
|
148
|
+
branch: options.branch,
|
|
149
|
+
baseBranch: options.baseBranch,
|
|
150
|
+
baseCommit: options.baseCommit,
|
|
151
|
+
ownershipToken,
|
|
152
|
+
createdAt: now.toISOString(),
|
|
153
|
+
worktreePath,
|
|
154
|
+
};
|
|
155
|
+
await writeFile(ownershipPath, `${JSON.stringify(ownership, null, 2)}\n`, "utf-8");
|
|
156
|
+
await ensureOwnershipFileIgnored(worktreePath);
|
|
157
|
+
const head = (await runGit(worktreePath, ["rev-parse", "HEAD"])).trim();
|
|
158
|
+
return {
|
|
159
|
+
path: worktreePath,
|
|
160
|
+
branch: options.branch,
|
|
161
|
+
baseBranch: options.baseBranch,
|
|
162
|
+
baseCommit: options.baseCommit,
|
|
163
|
+
ownershipToken,
|
|
164
|
+
created: true,
|
|
165
|
+
reused: false,
|
|
166
|
+
head,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
export async function readWorktreeOwnership(worktreePath) {
|
|
170
|
+
return readOwnershipRecord(path.join(worktreePath, OWNERSHIP_FILENAME));
|
|
171
|
+
}
|
|
172
|
+
export async function verifyOwnedWorktree(input) {
|
|
173
|
+
const resolvedRoot = await resolveRepoRoot(input.repoRoot);
|
|
174
|
+
const worktreePath = resolveAgainstCanonicalRepoRoot(input.repoRoot, resolvedRoot, input.worktreePath);
|
|
175
|
+
assertOwnedWorktreePath(resolvedRoot, worktreePath);
|
|
176
|
+
const ownership = await readWorktreeOwnership(worktreePath);
|
|
177
|
+
if (!ownership) {
|
|
178
|
+
throw new Error(`missing worktree ownership at ${worktreePath}`);
|
|
179
|
+
}
|
|
180
|
+
if (ownership.scheduleId !== input.scheduleId) {
|
|
181
|
+
throw new Error(`worktree ownership schedule mismatch: expected ${input.scheduleId}, found ${ownership.scheduleId}`);
|
|
182
|
+
}
|
|
183
|
+
if (ownership.ownershipToken !== input.ownershipToken) {
|
|
184
|
+
throw new Error(`worktree ownership token mismatch at ${worktreePath}`);
|
|
185
|
+
}
|
|
186
|
+
if (ownership.branch !== input.branch ||
|
|
187
|
+
ownership.baseCommit !== input.baseCommit) {
|
|
188
|
+
throw new Error(`worktree ownership branch/base mismatch at ${worktreePath}`);
|
|
189
|
+
}
|
|
190
|
+
const entries = await listWorktrees(resolvedRoot);
|
|
191
|
+
const registered = entries.find((entry) => path.normalize(entry.path) === path.normalize(worktreePath));
|
|
192
|
+
if (!registered) {
|
|
193
|
+
throw new Error(`worktree not registered: ${worktreePath}`);
|
|
194
|
+
}
|
|
195
|
+
if (registered.branch && registered.branch !== input.branch) {
|
|
196
|
+
throw new Error(`worktree registry branch mismatch: expected ${input.branch}, found ${registered.branch}`);
|
|
197
|
+
}
|
|
198
|
+
const head = (await runGit(worktreePath, ["rev-parse", "HEAD"])).trim();
|
|
199
|
+
if (head !== input.baseCommit) {
|
|
200
|
+
throw new Error(`worktree HEAD drift at ${worktreePath}: expected ${input.baseCommit}, found ${head}`);
|
|
201
|
+
}
|
|
202
|
+
await ensureOwnershipFileIgnored(worktreePath);
|
|
203
|
+
return { head, ownership };
|
|
204
|
+
}
|
|
64
205
|
/**
|
|
65
206
|
* List git worktrees by parsing `git worktree list --porcelain`.
|
|
66
207
|
*/
|
|
67
208
|
export async function listWorktrees(repoRoot) {
|
|
68
209
|
const resolvedRoot = await resolveRepoRoot(repoRoot);
|
|
69
|
-
const stdout = await runGit(resolvedRoot, [
|
|
210
|
+
const stdout = await runGit(resolvedRoot, [
|
|
211
|
+
"worktree",
|
|
212
|
+
"list",
|
|
213
|
+
"--porcelain",
|
|
214
|
+
]);
|
|
70
215
|
const entries = [];
|
|
71
|
-
const blocks = stdout.split(
|
|
216
|
+
const blocks = stdout.split("\n\n").filter((b) => b.trim().length > 0);
|
|
72
217
|
for (const block of blocks) {
|
|
73
|
-
const lines = block.trim().split(
|
|
74
|
-
let entryPath =
|
|
218
|
+
const lines = block.trim().split("\n");
|
|
219
|
+
let entryPath = "";
|
|
75
220
|
let branch;
|
|
76
|
-
let head =
|
|
221
|
+
let head = "";
|
|
77
222
|
let bare = false;
|
|
78
223
|
let locked = false;
|
|
79
224
|
for (const line of lines) {
|
|
80
|
-
if (line.startsWith(
|
|
81
|
-
entryPath = line.slice(
|
|
225
|
+
if (line.startsWith("worktree ")) {
|
|
226
|
+
entryPath = line.slice("worktree ".length);
|
|
82
227
|
}
|
|
83
|
-
else if (line.startsWith(
|
|
84
|
-
const ref = line.slice(
|
|
228
|
+
else if (line.startsWith("branch ")) {
|
|
229
|
+
const ref = line.slice("branch ".length);
|
|
85
230
|
// Convert refs/heads/<name> to <name>
|
|
86
|
-
branch = ref.replace(/^refs\/heads\//,
|
|
231
|
+
branch = ref.replace(/^refs\/heads\//, "");
|
|
87
232
|
}
|
|
88
|
-
else if (line.startsWith(
|
|
89
|
-
head = line.slice(
|
|
233
|
+
else if (line.startsWith("HEAD ")) {
|
|
234
|
+
head = line.slice("HEAD ".length);
|
|
90
235
|
}
|
|
91
|
-
else if (line ===
|
|
236
|
+
else if (line === "bare") {
|
|
92
237
|
bare = true;
|
|
93
238
|
}
|
|
94
|
-
else if (line ===
|
|
239
|
+
else if (line === "locked") {
|
|
95
240
|
locked = true;
|
|
96
241
|
}
|
|
97
242
|
}
|
|
98
243
|
if (entryPath) {
|
|
99
|
-
entries.push({
|
|
244
|
+
entries.push({
|
|
245
|
+
path: path.normalize(entryPath),
|
|
246
|
+
branch,
|
|
247
|
+
head,
|
|
248
|
+
bare,
|
|
249
|
+
locked,
|
|
250
|
+
});
|
|
100
251
|
}
|
|
101
252
|
}
|
|
102
253
|
return entries.sort((a, b) => a.path.localeCompare(b.path));
|
|
@@ -107,13 +258,16 @@ export async function listWorktrees(repoRoot) {
|
|
|
107
258
|
*/
|
|
108
259
|
export async function removeWorktree(repoRoot, manifest, opts) {
|
|
109
260
|
const resolvedRoot = await resolveRepoRoot(repoRoot);
|
|
110
|
-
const worktreePath = opts.path ??
|
|
261
|
+
const worktreePath = opts.path ??
|
|
262
|
+
(opts.taskId
|
|
263
|
+
? resolveWorktreePath(resolvedRoot, manifest, opts.taskId)
|
|
264
|
+
: undefined);
|
|
111
265
|
if (!worktreePath) {
|
|
112
|
-
throw new Error(
|
|
266
|
+
throw new Error("removeWorktree requires either taskId or path");
|
|
113
267
|
}
|
|
114
|
-
const removeArgs = [
|
|
268
|
+
const removeArgs = ["worktree", "remove"];
|
|
115
269
|
if (opts.force) {
|
|
116
|
-
removeArgs.push(
|
|
270
|
+
removeArgs.push("--force");
|
|
117
271
|
}
|
|
118
272
|
removeArgs.push(worktreePath);
|
|
119
273
|
await runGit(resolvedRoot, removeArgs);
|
|
@@ -121,7 +275,7 @@ export async function removeWorktree(repoRoot, manifest, opts) {
|
|
|
121
275
|
if (opts.deleteBranch && opts.taskId) {
|
|
122
276
|
const branch = `task/${opts.taskId}`;
|
|
123
277
|
try {
|
|
124
|
-
await runGit(resolvedRoot, [
|
|
278
|
+
await runGit(resolvedRoot, ["branch", "-d", branch]);
|
|
125
279
|
}
|
|
126
280
|
catch {
|
|
127
281
|
// Branch may already be deleted or not exist; ignore
|
|
@@ -136,21 +290,21 @@ export async function removeWorktree(repoRoot, manifest, opts) {
|
|
|
136
290
|
*/
|
|
137
291
|
async function runGit(cwd, args) {
|
|
138
292
|
return new Promise((resolve, reject) => {
|
|
139
|
-
const child = spawn(
|
|
140
|
-
stdio: [
|
|
293
|
+
const child = spawn("git", ["-C", cwd, ...args], {
|
|
294
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
141
295
|
});
|
|
142
296
|
const stdoutChunks = [];
|
|
143
297
|
const stderrChunks = [];
|
|
144
|
-
child.stdout?.on(
|
|
145
|
-
child.stderr?.on(
|
|
146
|
-
child.on(
|
|
147
|
-
reject(new Error(`git ${args.join(
|
|
298
|
+
child.stdout?.on("data", (chunk) => stdoutChunks.push(chunk));
|
|
299
|
+
child.stderr?.on("data", (chunk) => stderrChunks.push(chunk));
|
|
300
|
+
child.on("error", (err) => {
|
|
301
|
+
reject(new Error(`git ${args.join(" ")} spawn error: ${err.message}`));
|
|
148
302
|
});
|
|
149
|
-
child.on(
|
|
150
|
-
const stdout = Buffer.concat(stdoutChunks).toString(
|
|
151
|
-
const stderr = Buffer.concat(stderrChunks).toString(
|
|
303
|
+
child.on("close", (code) => {
|
|
304
|
+
const stdout = Buffer.concat(stdoutChunks).toString("utf-8");
|
|
305
|
+
const stderr = Buffer.concat(stderrChunks).toString("utf-8");
|
|
152
306
|
if (code !== 0) {
|
|
153
|
-
reject(new Error(`git ${args.join(
|
|
307
|
+
reject(new Error(`git ${args.join(" ")} failed (exit ${code}): ${stderr.trim()}`));
|
|
154
308
|
return;
|
|
155
309
|
}
|
|
156
310
|
resolve(stdout);
|
|
@@ -162,10 +316,73 @@ async function runGit(cwd, args) {
|
|
|
162
316
|
*/
|
|
163
317
|
async function gitBranchExists(cwd, branchName) {
|
|
164
318
|
try {
|
|
165
|
-
const stdout = await runGit(cwd, [
|
|
319
|
+
const stdout = await runGit(cwd, ["branch", "--list", branchName]);
|
|
166
320
|
return stdout.trim().length > 0;
|
|
167
321
|
}
|
|
168
322
|
catch {
|
|
169
323
|
return false;
|
|
170
324
|
}
|
|
171
325
|
}
|
|
326
|
+
function resolveAgainstCanonicalRepoRoot(providedRepoRoot, resolvedRepoRoot, candidatePath) {
|
|
327
|
+
if (!path.isAbsolute(candidatePath)) {
|
|
328
|
+
return path.resolve(resolvedRepoRoot, candidatePath);
|
|
329
|
+
}
|
|
330
|
+
const relativeToProvided = path.relative(path.resolve(providedRepoRoot), path.resolve(candidatePath));
|
|
331
|
+
if (!relativeToProvided.startsWith("..") &&
|
|
332
|
+
!path.isAbsolute(relativeToProvided)) {
|
|
333
|
+
return path.resolve(resolvedRepoRoot, relativeToProvided);
|
|
334
|
+
}
|
|
335
|
+
return path.resolve(candidatePath);
|
|
336
|
+
}
|
|
337
|
+
function assertOwnedWorktreePath(resolvedRepoRoot, worktreePath) {
|
|
338
|
+
const worktreeRoot = path.resolve(resolvedRepoRoot, ".worktrees");
|
|
339
|
+
const relative = path.relative(worktreeRoot, path.resolve(worktreePath));
|
|
340
|
+
if (relative.length === 0 ||
|
|
341
|
+
relative.startsWith("..") ||
|
|
342
|
+
path.isAbsolute(relative)) {
|
|
343
|
+
throw new Error(`owned worktree path must be a child of ${worktreeRoot}: ${worktreePath}`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
async function ensureOwnershipFileIgnored(worktreePath) {
|
|
347
|
+
const excludeRef = (await runGit(worktreePath, ["rev-parse", "--git-path", "info/exclude"])).trim();
|
|
348
|
+
const excludePath = path.isAbsolute(excludeRef)
|
|
349
|
+
? excludeRef
|
|
350
|
+
: path.resolve(worktreePath, excludeRef);
|
|
351
|
+
const ignoreLine = `/${OWNERSHIP_FILENAME}`;
|
|
352
|
+
let existing = "";
|
|
353
|
+
try {
|
|
354
|
+
existing = await readFile(excludePath, "utf-8");
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
if (error.code !== "ENOENT")
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
if (existing
|
|
361
|
+
.split(/\r?\n/)
|
|
362
|
+
.map((line) => line.trim())
|
|
363
|
+
.includes(ignoreLine)) {
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
await mkdir(path.dirname(excludePath), { recursive: true });
|
|
367
|
+
const separator = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
|
|
368
|
+
await appendFile(excludePath, `${separator}${ignoreLine}\n`, "utf-8");
|
|
369
|
+
}
|
|
370
|
+
async function readOwnershipRecord(ownershipPath) {
|
|
371
|
+
try {
|
|
372
|
+
const raw = await readFile(ownershipPath, "utf-8");
|
|
373
|
+
const parsed = JSON.parse(raw);
|
|
374
|
+
if (parsed?.schemaVersion !== 1 ||
|
|
375
|
+
typeof parsed.scheduleId !== "string" ||
|
|
376
|
+
typeof parsed.ownershipToken !== "string" ||
|
|
377
|
+
typeof parsed.branch !== "string" ||
|
|
378
|
+
typeof parsed.baseCommit !== "string") {
|
|
379
|
+
return undefined;
|
|
380
|
+
}
|
|
381
|
+
return parsed;
|
|
382
|
+
}
|
|
383
|
+
catch (error) {
|
|
384
|
+
if (error.code === "ENOENT")
|
|
385
|
+
return undefined;
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
}
|
package/dist/worker/cli.js
CHANGED
|
@@ -31,12 +31,13 @@ import { approveFollowUp } from "./follow-up/approve.js";
|
|
|
31
31
|
import { prepareFeatureDelivery } from "./delivery/package.js";
|
|
32
32
|
import { previewFeatureCloseout, renderCloseoutPreview, } from "./closeout/preview.js";
|
|
33
33
|
import { applyFeatureCloseout } from "./closeout/apply.js";
|
|
34
|
-
import { advanceFeature, renderFeatureAdvance
|
|
35
|
-
import { diagnoseFeature, renderFeatureDoctor
|
|
34
|
+
import { advanceFeature, renderFeatureAdvance } from "./feature/advance.js";
|
|
35
|
+
import { diagnoseFeature, renderFeatureDoctor } from "./feature/doctor.js";
|
|
36
36
|
import { FEATURE_SCAFFOLD_TEMPLATES, ScaffoldError, scaffoldFeaturePacket, } from "./feature/scaffold.js";
|
|
37
37
|
import { projectMonthlyMetrics } from "./metrics/projector.js";
|
|
38
38
|
import { defaultFeatureEvidencePaths, runFeatureFinalVerification, } from "./delivery/final-verification.js";
|
|
39
39
|
import { advanceGitCheckpoint } from "./delivery/git-transaction.js";
|
|
40
|
+
import { registerSchedulerCommands } from "./scheduler/cli.js";
|
|
40
41
|
export function buildAgentWorkerProgram() {
|
|
41
42
|
const program = new Command();
|
|
42
43
|
program
|
|
@@ -65,6 +66,7 @@ export function buildAgentWorkerProgram() {
|
|
|
65
66
|
const pool = program
|
|
66
67
|
.command("pool")
|
|
67
68
|
.description("Task Pool state doctor and migration utilities");
|
|
69
|
+
registerSchedulerCommands(program);
|
|
68
70
|
pool
|
|
69
71
|
.command("mark-failed")
|
|
70
72
|
.requiredOption("--repo <repo-root>", "Target repo root")
|
|
@@ -285,9 +287,7 @@ export function buildAgentWorkerProgram() {
|
|
|
285
287
|
controllerIdentity: client.getIdentity(),
|
|
286
288
|
controllerExpectation: buildIdentityExpectation(options),
|
|
287
289
|
evidenceMode: options.evidenceMode,
|
|
288
|
-
...(options.qaEvidence
|
|
289
|
-
? { qaEvidencePath: options.qaEvidence }
|
|
290
|
-
: {}),
|
|
290
|
+
...(options.qaEvidence ? { qaEvidencePath: options.qaEvidence } : {}),
|
|
291
291
|
...(options.finalVerification
|
|
292
292
|
? { finalVerificationPath: options.finalVerification }
|
|
293
293
|
: {}),
|
|
@@ -299,8 +299,7 @@ export function buildAgentWorkerProgram() {
|
|
|
299
299
|
process.stdout.write(options.json
|
|
300
300
|
? `${JSON.stringify(result, null, 2)}\n`
|
|
301
301
|
: renderFeatureAdvance(result));
|
|
302
|
-
if (result.status === "failed" ||
|
|
303
|
-
result.status === "blocked") {
|
|
302
|
+
if (result.status === "failed" || result.status === "blocked") {
|
|
304
303
|
process.exitCode = 1;
|
|
305
304
|
}
|
|
306
305
|
});
|
|
@@ -350,8 +349,7 @@ export function buildAgentWorkerProgram() {
|
|
|
350
349
|
feature: {
|
|
351
350
|
featureId: options.featureId ?? "",
|
|
352
351
|
title: options.title ?? "",
|
|
353
|
-
template: (options.template ??
|
|
354
|
-
""),
|
|
352
|
+
template: (options.template ?? ""),
|
|
355
353
|
description: options.description,
|
|
356
354
|
bePaths: options.bePath,
|
|
357
355
|
fePaths: options.fePath,
|
|
@@ -791,18 +789,30 @@ export function buildAgentWorkerProgram() {
|
|
|
791
789
|
.command("morning")
|
|
792
790
|
.requiredOption("--repo <repo-root>", "Target repo root")
|
|
793
791
|
.option("--batch-run-id <id>", "Filter to one batch run id")
|
|
792
|
+
.option("--window <window>", "Report window: all (Task Pool classic) or night (Night Scheduler)", "all")
|
|
793
|
+
.option("--date <yyyy-mm-dd>", "Local calendar date for --window night (default today)")
|
|
794
|
+
.option("--tz <timezone>", "IANA timezone for --window night", "Asia/Shanghai")
|
|
794
795
|
.option("--output <path>", "Write markdown report to this path")
|
|
795
796
|
.description("Render a markdown morning report from Task Pool runs")
|
|
796
797
|
.action(async (options) => {
|
|
797
798
|
const repoRoot = path.resolve(options.repo);
|
|
799
|
+
if (options.window !== "all" && options.window !== "night") {
|
|
800
|
+
throw new Error(`--window must be all or night, got ${options.window ?? ""}`);
|
|
801
|
+
}
|
|
802
|
+
const window = options.window;
|
|
798
803
|
const outputPath = options.output ??
|
|
799
|
-
path.join(getTaskPoolRoot(repoRoot), "reports", "
|
|
804
|
+
path.join(getTaskPoolRoot(repoRoot), "reports", window === "night"
|
|
805
|
+
? `morning-night-${options.date ?? "latest"}.md`
|
|
806
|
+
: "morning-report.md");
|
|
800
807
|
await writeMorningReport({
|
|
801
808
|
repoRoot,
|
|
802
809
|
...(options.batchRunId ? { batchRunId: options.batchRunId } : {}),
|
|
810
|
+
window,
|
|
811
|
+
...(options.date ? { date: options.date } : {}),
|
|
812
|
+
...(options.tz ? { timezone: options.tz } : {}),
|
|
803
813
|
outputPath: path.resolve(outputPath),
|
|
804
814
|
});
|
|
805
|
-
process.stdout.write(`${JSON.stringify({ ok: true, outputPath }, null, 2)}\n`);
|
|
815
|
+
process.stdout.write(`${JSON.stringify({ ok: true, outputPath, window }, null, 2)}\n`);
|
|
806
816
|
});
|
|
807
817
|
report
|
|
808
818
|
.command("metrics")
|
|
@@ -945,7 +955,7 @@ function isDirectRun() {
|
|
|
945
955
|
}
|
|
946
956
|
if (isDirectRun()) {
|
|
947
957
|
main().catch((error) => {
|
|
948
|
-
|
|
958
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
949
959
|
process.exit(1);
|
|
950
960
|
});
|
|
951
961
|
}
|
|
@@ -10,8 +10,12 @@ function configuredLanding(value) {
|
|
|
10
10
|
}
|
|
11
11
|
function configuredWorkspace(value) {
|
|
12
12
|
const normalized = value?.trim().toLowerCase();
|
|
13
|
-
if (normalized === "chat" ||
|
|
13
|
+
if (normalized === "chat" ||
|
|
14
|
+
normalized === "tasks" ||
|
|
15
|
+
normalized === "recovery" ||
|
|
16
|
+
normalized === "night") {
|
|
14
17
|
return normalized;
|
|
18
|
+
}
|
|
15
19
|
return undefined;
|
|
16
20
|
}
|
|
17
21
|
function workspaceFromLocation(location) {
|
|
@@ -21,14 +25,16 @@ function workspaceFromLocation(location) {
|
|
|
21
25
|
const queryValue = configuredLanding(query.get("landing") ?? query.get("workspace"));
|
|
22
26
|
if (queryValue)
|
|
23
27
|
return queryValue;
|
|
24
|
-
const hash = location.hash.startsWith("#")
|
|
28
|
+
const hash = location.hash.startsWith("#")
|
|
29
|
+
? location.hash.slice(1)
|
|
30
|
+
: location.hash;
|
|
25
31
|
return configuredLanding(new URLSearchParams(hash).get("workspace"));
|
|
26
32
|
}
|
|
27
33
|
export function resolveDefaultLanding(input) {
|
|
28
|
-
return workspaceFromLocation(input.location) ??
|
|
34
|
+
return (workspaceFromLocation(input.location) ??
|
|
29
35
|
configuredLanding(input.storage?.getItem(DEFAULT_LANDING_STORAGE_KEY)) ??
|
|
30
36
|
configuredLanding(input.env) ??
|
|
31
|
-
"tasks";
|
|
37
|
+
"tasks");
|
|
32
38
|
}
|
|
33
39
|
export function buildWorkspaceHref(workspace, params = {}) {
|
|
34
40
|
const query = new URLSearchParams({ workspace });
|
|
@@ -40,9 +46,13 @@ export function buildWorkspaceHref(workspace, params = {}) {
|
|
|
40
46
|
}
|
|
41
47
|
export function parseWorkspaceHref(location) {
|
|
42
48
|
const query = new URLSearchParams(location.search);
|
|
43
|
-
const hash = location.hash.startsWith("#")
|
|
49
|
+
const hash = location.hash.startsWith("#")
|
|
50
|
+
? location.hash.slice(1)
|
|
51
|
+
: location.hash;
|
|
44
52
|
const hashQuery = new URLSearchParams(hash);
|
|
45
|
-
const workspace = configuredWorkspace(query.get("workspace") ??
|
|
53
|
+
const workspace = configuredWorkspace(query.get("workspace") ??
|
|
54
|
+
query.get("landing") ??
|
|
55
|
+
hashQuery.get("workspace"));
|
|
46
56
|
if (!workspace)
|
|
47
57
|
return null;
|
|
48
58
|
const read = (key) => query.get(key)?.trim() || undefined;
|
|
@@ -16,6 +16,8 @@ export function capabilityForObserveTarget(target) {
|
|
|
16
16
|
return "workerRun";
|
|
17
17
|
case "batch":
|
|
18
18
|
return "batch";
|
|
19
|
+
case "night":
|
|
20
|
+
return "nightJobs";
|
|
19
21
|
default: {
|
|
20
22
|
const _exhaustive = target;
|
|
21
23
|
throw new Error(`capabilityForObserveTarget: unsupported ${JSON.stringify(_exhaustive)}`);
|
|
@@ -26,6 +26,10 @@ export function buildObserveDeepLink(baseUrl, target) {
|
|
|
26
26
|
return `${base}/#/run/${encodeURIComponent(target.workerRunId)}`;
|
|
27
27
|
case "batch":
|
|
28
28
|
return `${base}/#/batch/${encodeURIComponent(target.batchRunId)}`;
|
|
29
|
+
case "night":
|
|
30
|
+
return target.scheduleId
|
|
31
|
+
? `${base}/#/night/${encodeURIComponent(target.scheduleId)}`
|
|
32
|
+
: `${base}/#/night`;
|
|
29
33
|
default: {
|
|
30
34
|
const _exhaustive = target;
|
|
31
35
|
throw new Error(`buildObserveDeepLink: unsupported target ${JSON.stringify(_exhaustive)}`);
|