@tea-agent/loop-agent 0.28.2-beta.1 → 0.28.2
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 +25 -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 +536 -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 +52 -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 +421 -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
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { listSchedules } from "./store.js";
|
|
2
|
+
/**
|
|
3
|
+
* Project Night Scheduler facts for morning report --window night.
|
|
4
|
+
* Date is local calendar day in display timezone (default Asia/Shanghai).
|
|
5
|
+
*/
|
|
6
|
+
export async function buildNightMorningWindow(input) {
|
|
7
|
+
const timezone = input.timezone ?? "Asia/Shanghai";
|
|
8
|
+
const now = input.now ?? new Date();
|
|
9
|
+
const date = input.date ?? formatLocalDate(now, timezone);
|
|
10
|
+
const schedules = await listSchedules(input.controlRepoRoot);
|
|
11
|
+
const inWindow = schedules.filter((schedule) => scheduleTouchesLocalDate(schedule, date, timezone));
|
|
12
|
+
const rows = inWindow
|
|
13
|
+
.map((schedule) => toRow(schedule))
|
|
14
|
+
.sort((a, b) => (a.executeAtUtc ?? "").localeCompare(b.executeAtUtc ?? ""));
|
|
15
|
+
const summary = {
|
|
16
|
+
total: rows.length,
|
|
17
|
+
succeeded: rows.filter((row) => row.status === "succeeded").length,
|
|
18
|
+
failed: rows.filter((row) => row.status === "failed").length,
|
|
19
|
+
humanRequired: rows.filter((row) => row.status === "human_required").length,
|
|
20
|
+
pendingHarvest: rows.filter((row) => row.mergeState === "pending-harvest")
|
|
21
|
+
.length,
|
|
22
|
+
scheduledOrWaiting: rows.filter((row) => row.status === "scheduled" ||
|
|
23
|
+
row.status === "waiting" ||
|
|
24
|
+
row.status === "validated").length,
|
|
25
|
+
};
|
|
26
|
+
return {
|
|
27
|
+
schemaVersion: 1,
|
|
28
|
+
window: "night",
|
|
29
|
+
date,
|
|
30
|
+
timezone,
|
|
31
|
+
generatedAt: now.toISOString(),
|
|
32
|
+
schedules: rows,
|
|
33
|
+
summary,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function renderNightMorningMarkdown(window) {
|
|
37
|
+
const lines = [
|
|
38
|
+
`# Night Scheduler Morning Report (${window.date})`,
|
|
39
|
+
"",
|
|
40
|
+
`Timezone: ${window.timezone}`,
|
|
41
|
+
`Generated: ${window.generatedAt}`,
|
|
42
|
+
"",
|
|
43
|
+
"## Summary",
|
|
44
|
+
"",
|
|
45
|
+
`- Total schedules: ${window.summary.total}`,
|
|
46
|
+
`- Succeeded: ${window.summary.succeeded}`,
|
|
47
|
+
`- Failed: ${window.summary.failed}`,
|
|
48
|
+
`- Human required: ${window.summary.humanRequired}`,
|
|
49
|
+
`- Pending harvest: ${window.summary.pendingHarvest}`,
|
|
50
|
+
`- Still scheduled/waiting: ${window.summary.scheduledOrWaiting}`,
|
|
51
|
+
"",
|
|
52
|
+
"## Schedules",
|
|
53
|
+
"",
|
|
54
|
+
"| Schedule | Task | Plan | Status | Merge | Card | Next |",
|
|
55
|
+
"|---|---|---|---|---|---|---|",
|
|
56
|
+
];
|
|
57
|
+
for (const row of window.schedules) {
|
|
58
|
+
lines.push(`| ${row.scheduleId} | ${row.featureId}/${row.taskId} | ${row.executeAtUtc ?? "-"} | ${row.status} | ${row.mergeState ?? "-"} | ${row.taskCard ?? "-"} | ${row.nextAction} |`);
|
|
59
|
+
}
|
|
60
|
+
if (window.schedules.length === 0) {
|
|
61
|
+
lines.push("| (none) | - | - | - | - | - | - |");
|
|
62
|
+
}
|
|
63
|
+
lines.push("", "## Details", "");
|
|
64
|
+
for (const row of window.schedules) {
|
|
65
|
+
lines.push(`### ${row.scheduleId}`);
|
|
66
|
+
lines.push("");
|
|
67
|
+
lines.push(`- Task: ${row.featureId}/${row.taskId}`);
|
|
68
|
+
lines.push(`- Status: ${row.status}`);
|
|
69
|
+
lines.push(`- Plan: ${row.executeAtUtc ?? "-"}`);
|
|
70
|
+
lines.push(`- Merge: ${row.mergeState ?? "-"}`);
|
|
71
|
+
lines.push(`- Worktree: ${row.worktreePath ?? "-"}`);
|
|
72
|
+
lines.push(`- Branch: ${row.branch ?? "-"}`);
|
|
73
|
+
lines.push(`- Card: ${row.taskCard ?? "-"}`);
|
|
74
|
+
lines.push(`- Execution: ${row.currentExecutionId ?? "-"}`);
|
|
75
|
+
lines.push(`- Evidence: ${row.evidencePath ?? "-"}`);
|
|
76
|
+
lines.push(`- Next: ${row.nextAction}`);
|
|
77
|
+
lines.push("");
|
|
78
|
+
}
|
|
79
|
+
return `${lines.join("\n")}\n`;
|
|
80
|
+
}
|
|
81
|
+
function toRow(schedule) {
|
|
82
|
+
const mergeState = schedule.isolation?.mergeState;
|
|
83
|
+
const nextAction = resolveNextAction(schedule);
|
|
84
|
+
return {
|
|
85
|
+
scheduleId: schedule.id,
|
|
86
|
+
featureId: schedule.featureId,
|
|
87
|
+
taskId: schedule.taskId,
|
|
88
|
+
status: schedule.status,
|
|
89
|
+
...(schedule.trigger?.executeAtUtc
|
|
90
|
+
? { executeAtUtc: schedule.trigger.executeAtUtc }
|
|
91
|
+
: {}),
|
|
92
|
+
...(mergeState ? { mergeState } : {}),
|
|
93
|
+
...(schedule.isolation?.worktreePath
|
|
94
|
+
? { worktreePath: schedule.isolation.worktreePath }
|
|
95
|
+
: {}),
|
|
96
|
+
...(schedule.isolation?.branch
|
|
97
|
+
? { branch: schedule.isolation.branch }
|
|
98
|
+
: {}),
|
|
99
|
+
...(schedule.traceability?.workItemRef
|
|
100
|
+
? { taskCard: schedule.traceability.workItemRef }
|
|
101
|
+
: {}),
|
|
102
|
+
currentExecutionId: schedule.currentExecutionId ?? null,
|
|
103
|
+
...(schedule.currentExecutionId
|
|
104
|
+
? {
|
|
105
|
+
evidencePath: `.harness/task-pool/scheduler/evidence/${schedule.currentExecutionId}`,
|
|
106
|
+
}
|
|
107
|
+
: {}),
|
|
108
|
+
nextAction,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
export function resolveNextAction(schedule) {
|
|
112
|
+
if (schedule.status === "succeeded" &&
|
|
113
|
+
schedule.isolation?.mergeState === "pending-harvest") {
|
|
114
|
+
return `agent-worker scheduler harvest ${schedule.id} --repo .`;
|
|
115
|
+
}
|
|
116
|
+
if (schedule.status === "succeeded" &&
|
|
117
|
+
schedule.isolation?.mergeState === "merged") {
|
|
118
|
+
return "done (merged)";
|
|
119
|
+
}
|
|
120
|
+
if (schedule.status === "failed" ||
|
|
121
|
+
schedule.status === "human_required" ||
|
|
122
|
+
schedule.status === "cancelled") {
|
|
123
|
+
return `agent-worker scheduler discard ${schedule.id} --repo . --reason "..."${schedule.status === "failed" || schedule.status === "human_required" ? " --force" : ""}`;
|
|
124
|
+
}
|
|
125
|
+
if (schedule.status === "scheduled" || schedule.status === "waiting") {
|
|
126
|
+
return "wait for scheduler tick / inspect schedule status";
|
|
127
|
+
}
|
|
128
|
+
if (schedule.status === "validated") {
|
|
129
|
+
return `agent-worker scheduler add ${schedule.id} --approve-gate <token> --repo .`;
|
|
130
|
+
}
|
|
131
|
+
if (schedule.status === "running" || schedule.status === "dispatching") {
|
|
132
|
+
return "monitor running execution; do not start a second writer";
|
|
133
|
+
}
|
|
134
|
+
return `agent-worker scheduler status ${schedule.id} --repo .`;
|
|
135
|
+
}
|
|
136
|
+
function scheduleTouchesLocalDate(schedule, date, timezone) {
|
|
137
|
+
const instants = [];
|
|
138
|
+
if (schedule.trigger?.executeAtUtc)
|
|
139
|
+
instants.push(schedule.trigger.executeAtUtc);
|
|
140
|
+
if (schedule.timestamps.submittedAt)
|
|
141
|
+
instants.push(schedule.timestamps.submittedAt);
|
|
142
|
+
if (schedule.timestamps.finishedAt)
|
|
143
|
+
instants.push(schedule.timestamps.finishedAt);
|
|
144
|
+
if (schedule.timestamps.startedAt)
|
|
145
|
+
instants.push(schedule.timestamps.startedAt);
|
|
146
|
+
if (instants.length === 0)
|
|
147
|
+
return true;
|
|
148
|
+
return instants.some((iso) => formatLocalDate(new Date(iso), timezone) === date);
|
|
149
|
+
}
|
|
150
|
+
function formatLocalDate(date, timezone) {
|
|
151
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
152
|
+
timeZone: timezone,
|
|
153
|
+
year: "numeric",
|
|
154
|
+
month: "2-digit",
|
|
155
|
+
day: "2-digit",
|
|
156
|
+
}).formatToParts(date);
|
|
157
|
+
const year = parts.find((part) => part.type === "year")?.value ?? "1970";
|
|
158
|
+
const month = parts.find((part) => part.type === "month")?.value ?? "01";
|
|
159
|
+
const day = parts.find((part) => part.type === "day")?.value ?? "01";
|
|
160
|
+
return `${year}-${month}-${day}`;
|
|
161
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { parseGitStatusPorcelain, pathMatchesPattern, } from "../../shared/git-progress.js";
|
|
3
|
+
import { formatCommitMessage, validateTaskCard } from "./traceability.js";
|
|
4
|
+
/**
|
|
5
|
+
* Create a single closing commit on the night branch after successful execution.
|
|
6
|
+
* Does not merge to base. Fail-closed on writeSet violations or missing task card.
|
|
7
|
+
*/
|
|
8
|
+
export async function createNightClosingCommit(input) {
|
|
9
|
+
const card = validateTaskCard(input.schedule.traceability?.workItemRef, {
|
|
10
|
+
required: input.schedule.traceability?.requiredOnEveryPushableCommit ??
|
|
11
|
+
input.schedule.traceability?.requiredOnCreate ??
|
|
12
|
+
true,
|
|
13
|
+
label: input.schedule.traceability?.label ?? "Task Card",
|
|
14
|
+
pattern: input.schedule.traceability?.pattern ??
|
|
15
|
+
"^[A-Z][A-Z0-9]+-[A-Z0-9]+#[0-9]+$",
|
|
16
|
+
commitMessageTemplate: input.schedule.traceability?.commitMessageTemplate ??
|
|
17
|
+
"{summary}\n\n{workItemRef}",
|
|
18
|
+
});
|
|
19
|
+
if (!card.ok || !card.workItemRef) {
|
|
20
|
+
return {
|
|
21
|
+
ok: false,
|
|
22
|
+
reason: card.ok ? "task card required for closing commit" : card.reason,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
const status = (await runGit(input.workspaceRoot, [
|
|
26
|
+
"status",
|
|
27
|
+
"--porcelain=v1",
|
|
28
|
+
"--untracked-files=all",
|
|
29
|
+
])).trim();
|
|
30
|
+
if (!status) {
|
|
31
|
+
return { ok: false, reason: "no changes to commit after execution" };
|
|
32
|
+
}
|
|
33
|
+
const changedPaths = parseGitStatusPorcelain(status).map((entry) => entry.path);
|
|
34
|
+
const writeSet = input.writeSet ?? input.admission.dag?.writeSet ?? undefined;
|
|
35
|
+
if (writeSet && writeSet.length > 0) {
|
|
36
|
+
const offenders = changedPaths.filter((p) => !pathAllowedByWriteSet(p, writeSet));
|
|
37
|
+
if (offenders.length > 0) {
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
reason: `changed paths outside frozen writeSet: ${offenders.join(", ")}`,
|
|
41
|
+
changedPaths,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
await runGit(input.workspaceRoot, ["add", "-A"]);
|
|
46
|
+
const summary = `feat(${input.schedule.taskId.toLowerCase()}): night schedule ${input.schedule.id}`;
|
|
47
|
+
const message = formatCommitMessage({
|
|
48
|
+
summary,
|
|
49
|
+
workItemRef: card.workItemRef,
|
|
50
|
+
template: input.schedule.traceability?.commitMessageTemplate,
|
|
51
|
+
trailers: {
|
|
52
|
+
"Worker-Run": input.workerRunId,
|
|
53
|
+
Schedule: input.schedule.id,
|
|
54
|
+
Execution: input.executionId,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
await runGit(input.workspaceRoot, ["commit", "-m", message]);
|
|
58
|
+
const commitSha = (await runGit(input.workspaceRoot, ["rev-parse", "HEAD"])).trim();
|
|
59
|
+
return {
|
|
60
|
+
ok: true,
|
|
61
|
+
commitSha,
|
|
62
|
+
message,
|
|
63
|
+
changedPaths,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function pathAllowedByWriteSet(filePath, writeSet) {
|
|
67
|
+
const normalized = filePath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
68
|
+
return writeSet.some((pattern) => pathMatchesPattern(normalized, pattern));
|
|
69
|
+
}
|
|
70
|
+
async function runGit(cwd, args) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const child = spawn("git", ["-C", cwd, ...args], {
|
|
73
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
74
|
+
});
|
|
75
|
+
const stdout = [];
|
|
76
|
+
const stderr = [];
|
|
77
|
+
child.stdout?.on("data", (c) => stdout.push(c));
|
|
78
|
+
child.stderr?.on("data", (c) => stderr.push(c));
|
|
79
|
+
child.on("error", (err) => reject(err));
|
|
80
|
+
child.on("close", (code) => {
|
|
81
|
+
if (code !== 0) {
|
|
82
|
+
reject(new Error(`git ${args.join(" ")} failed: ${Buffer.concat(stderr).toString("utf-8")}`));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
resolve(Buffer.concat(stdout).toString("utf-8"));
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { getLeasePath } from "./paths.js";
|
|
5
|
+
import { verifyEvidenceArchive } from "./evidence.js";
|
|
6
|
+
import { patchScheduleIsolation, transitionSchedule, } from "./lifecycle.js";
|
|
7
|
+
import { readAdmission, readExecution, readSchedule } from "./store.js";
|
|
8
|
+
import { releaseExecutionLease } from "./lease.js";
|
|
9
|
+
import { validateTaskCard } from "./traceability.js";
|
|
10
|
+
import { SCHEDULER_ERROR_CODES, SchedulerError, } from "./types.js";
|
|
11
|
+
/**
|
|
12
|
+
* MVP harvest: exact-base fast-forward only.
|
|
13
|
+
* Refuses when base HEAD moved, dirty, missing evidence, or bad mergeState.
|
|
14
|
+
*/
|
|
15
|
+
export async function harvestNightSchedule(input) {
|
|
16
|
+
const now = input.now ?? new Date();
|
|
17
|
+
const controlRepoRoot = path.resolve(input.controlRepoRoot);
|
|
18
|
+
const schedule = await readSchedule(controlRepoRoot, input.scheduleId);
|
|
19
|
+
if (!schedule) {
|
|
20
|
+
throw new SchedulerError(SCHEDULER_ERROR_CODES.NOT_FOUND, `schedule not found: ${input.scheduleId}`);
|
|
21
|
+
}
|
|
22
|
+
if (schedule.status !== "succeeded") {
|
|
23
|
+
return blocked("schedule-not-succeeded", `schedule status is ${schedule.status}, expected succeeded`, "Inspect schedule status / evidence; only succeeded pending-harvest can harvest");
|
|
24
|
+
}
|
|
25
|
+
if (schedule.isolation?.mergeState !== "pending-harvest") {
|
|
26
|
+
return blocked("merge-state-invalid", `mergeState is ${schedule.isolation?.mergeState ?? "missing"}, expected pending-harvest`, schedule.isolation?.mergeState === "merged"
|
|
27
|
+
? "Already harvested"
|
|
28
|
+
: "Use scheduler status / doctor before harvest");
|
|
29
|
+
}
|
|
30
|
+
const baseBranch = schedule.isolation.baseBranch;
|
|
31
|
+
const baseCommit = schedule.isolation.baseCommit;
|
|
32
|
+
const nightBranch = schedule.isolation.branch;
|
|
33
|
+
const worktreeRel = schedule.isolation.worktreePath;
|
|
34
|
+
if (!baseBranch || !baseCommit || !nightBranch) {
|
|
35
|
+
return blocked("isolation-incomplete", "schedule isolation missing baseBranch/baseCommit/branch", "Doctor admission/isolation facts");
|
|
36
|
+
}
|
|
37
|
+
if (nightBranch !== `night/${schedule.id}`) {
|
|
38
|
+
return blocked("night-branch-unsafe", `night branch must be night/${schedule.id}, got ${nightBranch}`, "Run scheduler doctor and repair the tampered isolation facts");
|
|
39
|
+
}
|
|
40
|
+
let safeWorktreePath;
|
|
41
|
+
if (worktreeRel) {
|
|
42
|
+
try {
|
|
43
|
+
safeWorktreePath = resolveSafeNightWorktreePath(controlRepoRoot, worktreeRel);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
return blocked("worktree-path-unsafe", error instanceof Error ? error.message : String(error), "Run scheduler doctor and repair the tampered isolation facts");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const baseStatus = (await runGit(controlRepoRoot, [
|
|
50
|
+
"status",
|
|
51
|
+
"--porcelain=v1",
|
|
52
|
+
"--untracked-files=all",
|
|
53
|
+
])).trim();
|
|
54
|
+
// Scheduler/Task Pool runtime under .harness and night worktrees are expected
|
|
55
|
+
// control facts; they must not block harvest of source commits.
|
|
56
|
+
const dirtySource = baseStatus
|
|
57
|
+
.split("\n")
|
|
58
|
+
.map((line) => line.trim())
|
|
59
|
+
.filter(Boolean)
|
|
60
|
+
.filter((line) => {
|
|
61
|
+
const filePath = line.slice(3).trim().replace(/\\/g, "/");
|
|
62
|
+
return (!filePath.startsWith(".harness/") &&
|
|
63
|
+
!filePath.startsWith(".worktrees/") &&
|
|
64
|
+
filePath !== ".harness" &&
|
|
65
|
+
filePath !== ".worktrees");
|
|
66
|
+
});
|
|
67
|
+
if (dirtySource.length > 0) {
|
|
68
|
+
return blocked("base-dirty", `control repo has uncommitted source changes: ${dirtySource
|
|
69
|
+
.slice(0, 5)
|
|
70
|
+
.map((line) => line.slice(3).trim())
|
|
71
|
+
.join(", ")}`, "Commit or stash base source changes, then retry harvest");
|
|
72
|
+
}
|
|
73
|
+
const currentBranch = (await runGit(controlRepoRoot, ["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
74
|
+
if (currentBranch !== baseBranch) {
|
|
75
|
+
return blocked("base-branch-mismatch", `checked out ${currentBranch}, expected base branch ${baseBranch}`, `git checkout ${baseBranch} then retry harvest`);
|
|
76
|
+
}
|
|
77
|
+
const head = (await runGit(controlRepoRoot, ["rev-parse", "HEAD"])).trim();
|
|
78
|
+
if (head !== baseCommit) {
|
|
79
|
+
return blocked("base-moved", `base HEAD ${head.slice(0, 12)} != frozen baseCommit ${baseCommit.slice(0, 12)}`, "MVP refuses harvest when base moved; integrate manually or open Phase 3.5+ integration worktree");
|
|
80
|
+
}
|
|
81
|
+
const isAncestor = await runGitAllowFail(controlRepoRoot, [
|
|
82
|
+
"merge-base",
|
|
83
|
+
"--is-ancestor",
|
|
84
|
+
baseCommit,
|
|
85
|
+
nightBranch,
|
|
86
|
+
]);
|
|
87
|
+
if (isAncestor.exitCode !== 0) {
|
|
88
|
+
return blocked("night-branch-not-descendant", `night branch ${nightBranch} is not a descendant of frozen baseCommit`, "Inspect night branch history; do not force FF");
|
|
89
|
+
}
|
|
90
|
+
// Traceability: every commit on night branch after base must mention work item when required.
|
|
91
|
+
const cardCheck = await auditBranchTraceability(controlRepoRoot, baseCommit, nightBranch, schedule);
|
|
92
|
+
if (!cardCheck.ok) {
|
|
93
|
+
return blocked("traceability-failed", cardCheck.reason, "Fix commit messages on night branch or discard");
|
|
94
|
+
}
|
|
95
|
+
// Evidence archive integrity when execution id present
|
|
96
|
+
if (schedule.currentExecutionId) {
|
|
97
|
+
const evidenceOk = await evidenceArchivePresent(controlRepoRoot, schedule.currentExecutionId);
|
|
98
|
+
if (!evidenceOk) {
|
|
99
|
+
return blocked("evidence-missing", `evidence archive missing for ${schedule.currentExecutionId}`, "Restore evidence or re-archive before harvest");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Fast-forward only
|
|
103
|
+
try {
|
|
104
|
+
await runGit(controlRepoRoot, ["merge", "--ff-only", nightBranch]);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
return blocked("ff-failed", error instanceof Error ? error.message : String(error), "Resolve history manually; MVP does not rebase/merge");
|
|
108
|
+
}
|
|
109
|
+
const mergedCommit = (await runGit(controlRepoRoot, ["rev-parse", "HEAD"])).trim();
|
|
110
|
+
let worktreeRemoved = false;
|
|
111
|
+
let branchDeleted = false;
|
|
112
|
+
if (!input.keepWorktree && safeWorktreePath) {
|
|
113
|
+
try {
|
|
114
|
+
await runGit(controlRepoRoot, [
|
|
115
|
+
"worktree",
|
|
116
|
+
"remove",
|
|
117
|
+
"--force",
|
|
118
|
+
safeWorktreePath,
|
|
119
|
+
]);
|
|
120
|
+
worktreeRemoved = true;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
try {
|
|
124
|
+
await rm(safeWorktreePath, { recursive: true, force: true });
|
|
125
|
+
await runGit(controlRepoRoot, ["worktree", "prune"]).catch(() => { });
|
|
126
|
+
worktreeRemoved = true;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
worktreeRemoved = false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
await runGit(controlRepoRoot, ["branch", "-d", nightBranch]);
|
|
134
|
+
branchDeleted = true;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
branchDeleted = false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// succeeded is already terminal — only patch mergeState + ledger (no status change).
|
|
141
|
+
await patchScheduleIsolation({
|
|
142
|
+
controlRepoRoot,
|
|
143
|
+
scheduleId: schedule.id,
|
|
144
|
+
isolation: {
|
|
145
|
+
mode: "git-worktree",
|
|
146
|
+
required: true,
|
|
147
|
+
baseBranch: schedule.isolation.baseBranch,
|
|
148
|
+
baseCommit: schedule.isolation.baseCommit,
|
|
149
|
+
worktreePath: schedule.isolation.worktreePath,
|
|
150
|
+
branch: schedule.isolation.branch,
|
|
151
|
+
mergePolicy: schedule.isolation.mergePolicy,
|
|
152
|
+
mergeState: "merged",
|
|
153
|
+
},
|
|
154
|
+
event: "merge_completed",
|
|
155
|
+
reason: `fast-forward ${nightBranch} -> ${baseBranch}`,
|
|
156
|
+
reasonCode: "merged",
|
|
157
|
+
...(schedule.currentExecutionId
|
|
158
|
+
? {
|
|
159
|
+
evidencePaths: [
|
|
160
|
+
`.harness/task-pool/scheduler/evidence/${schedule.currentExecutionId}`,
|
|
161
|
+
],
|
|
162
|
+
}
|
|
163
|
+
: {}),
|
|
164
|
+
now,
|
|
165
|
+
});
|
|
166
|
+
return {
|
|
167
|
+
ok: true,
|
|
168
|
+
scheduleId: schedule.id,
|
|
169
|
+
baseBranch,
|
|
170
|
+
baseCommit,
|
|
171
|
+
nightBranch,
|
|
172
|
+
mergedCommit,
|
|
173
|
+
fastForward: true,
|
|
174
|
+
worktreeRemoved,
|
|
175
|
+
branchDeleted,
|
|
176
|
+
mergeState: "merged",
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Discard night worktree/branch after evidence is retained.
|
|
181
|
+
* Failed/human_required require --force.
|
|
182
|
+
*/
|
|
183
|
+
export async function discardNightSchedule(input) {
|
|
184
|
+
const now = input.now ?? new Date();
|
|
185
|
+
const controlRepoRoot = path.resolve(input.controlRepoRoot);
|
|
186
|
+
const schedule = await readSchedule(controlRepoRoot, input.scheduleId);
|
|
187
|
+
if (!schedule) {
|
|
188
|
+
throw new SchedulerError(SCHEDULER_ERROR_CODES.NOT_FOUND, `schedule not found: ${input.scheduleId}`);
|
|
189
|
+
}
|
|
190
|
+
const activeRuntime = new Set(["dispatching", "running", "cancel_requested"]);
|
|
191
|
+
if (activeRuntime.has(schedule.status)) {
|
|
192
|
+
return blocked("still-running", `cannot discard schedule in status ${schedule.status}`, "Wait for terminal status or cancel first");
|
|
193
|
+
}
|
|
194
|
+
if ((schedule.status === "failed" || schedule.status === "human_required") &&
|
|
195
|
+
!input.force) {
|
|
196
|
+
return blocked("force-required", `discard of ${schedule.status} schedule requires --force`, `agent-worker scheduler discard ${schedule.id} --repo . --reason "..." --force`);
|
|
197
|
+
}
|
|
198
|
+
if (schedule.currentExecutionId) {
|
|
199
|
+
const evidenceOk = await evidenceArchivePresent(controlRepoRoot, schedule.currentExecutionId);
|
|
200
|
+
if (!evidenceOk) {
|
|
201
|
+
// Still allow discard if never executed (no execution file)
|
|
202
|
+
const execution = await readExecution(controlRepoRoot, schedule.currentExecutionId);
|
|
203
|
+
if (execution) {
|
|
204
|
+
return blocked("evidence-missing", `evidence archive missing for ${schedule.currentExecutionId}`, "Archive evidence before discard so control root retains forensics");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// Drop lease if any
|
|
209
|
+
await releaseExecutionLease({
|
|
210
|
+
controlRepoRoot,
|
|
211
|
+
scheduleId: schedule.id,
|
|
212
|
+
}).catch(() => { });
|
|
213
|
+
try {
|
|
214
|
+
await rm(getLeasePath(controlRepoRoot, schedule.id), { force: true });
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// ignore
|
|
218
|
+
}
|
|
219
|
+
const nightBranch = schedule.isolation?.branch;
|
|
220
|
+
const worktreeRel = schedule.isolation?.worktreePath;
|
|
221
|
+
if (nightBranch && nightBranch !== `night/${schedule.id}`) {
|
|
222
|
+
return blocked("night-branch-unsafe", `night branch must be night/${schedule.id}, got ${nightBranch}`, "Run scheduler doctor and repair the tampered isolation facts");
|
|
223
|
+
}
|
|
224
|
+
let safeWorktreePath;
|
|
225
|
+
if (worktreeRel) {
|
|
226
|
+
try {
|
|
227
|
+
safeWorktreePath = resolveSafeNightWorktreePath(controlRepoRoot, worktreeRel);
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
return blocked("worktree-path-unsafe", error instanceof Error ? error.message : String(error), "Run scheduler doctor and repair the tampered isolation facts");
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
let worktreeRemoved = false;
|
|
234
|
+
let branchDeleted = false;
|
|
235
|
+
if (safeWorktreePath) {
|
|
236
|
+
try {
|
|
237
|
+
await runGit(controlRepoRoot, [
|
|
238
|
+
"worktree",
|
|
239
|
+
"remove",
|
|
240
|
+
"--force",
|
|
241
|
+
safeWorktreePath,
|
|
242
|
+
]);
|
|
243
|
+
worktreeRemoved = true;
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
try {
|
|
247
|
+
await rm(safeWorktreePath, { recursive: true, force: true });
|
|
248
|
+
await runGit(controlRepoRoot, ["worktree", "prune"]).catch(() => { });
|
|
249
|
+
worktreeRemoved = true;
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
worktreeRemoved = false;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (nightBranch) {
|
|
257
|
+
try {
|
|
258
|
+
await runGit(controlRepoRoot, ["branch", "-D", nightBranch]);
|
|
259
|
+
branchDeleted = true;
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
branchDeleted = false;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// Keep schedule terminal status; only mergeState → discarded + ledger.
|
|
266
|
+
if (schedule.status === "scheduled" ||
|
|
267
|
+
schedule.status === "waiting" ||
|
|
268
|
+
schedule.status === "validated" ||
|
|
269
|
+
schedule.status === "submitted" ||
|
|
270
|
+
schedule.status === "validating") {
|
|
271
|
+
await transitionSchedule({
|
|
272
|
+
controlRepoRoot,
|
|
273
|
+
scheduleId: schedule.id,
|
|
274
|
+
toStatus: "cancelled",
|
|
275
|
+
event: "worktree_discarded",
|
|
276
|
+
reason: input.reason,
|
|
277
|
+
reasonCode: "discard",
|
|
278
|
+
patch: {
|
|
279
|
+
isolation: {
|
|
280
|
+
mode: "git-worktree",
|
|
281
|
+
required: true,
|
|
282
|
+
baseBranch: schedule.isolation?.baseBranch,
|
|
283
|
+
baseCommit: schedule.isolation?.baseCommit,
|
|
284
|
+
worktreePath: schedule.isolation?.worktreePath,
|
|
285
|
+
branch: schedule.isolation?.branch,
|
|
286
|
+
mergePolicy: schedule.isolation?.mergePolicy ?? "manual-on-success",
|
|
287
|
+
mergeState: "discarded",
|
|
288
|
+
},
|
|
289
|
+
cancelReason: input.reason,
|
|
290
|
+
},
|
|
291
|
+
now,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
await patchScheduleIsolation({
|
|
296
|
+
controlRepoRoot,
|
|
297
|
+
scheduleId: schedule.id,
|
|
298
|
+
isolation: {
|
|
299
|
+
mode: "git-worktree",
|
|
300
|
+
required: true,
|
|
301
|
+
baseBranch: schedule.isolation?.baseBranch,
|
|
302
|
+
baseCommit: schedule.isolation?.baseCommit,
|
|
303
|
+
worktreePath: schedule.isolation?.worktreePath,
|
|
304
|
+
branch: schedule.isolation?.branch,
|
|
305
|
+
mergePolicy: schedule.isolation?.mergePolicy ?? "manual-on-success",
|
|
306
|
+
mergeState: "discarded",
|
|
307
|
+
},
|
|
308
|
+
event: "worktree_discarded",
|
|
309
|
+
reason: input.reason,
|
|
310
|
+
reasonCode: "discarded",
|
|
311
|
+
...(schedule.currentExecutionId
|
|
312
|
+
? {
|
|
313
|
+
evidencePaths: [
|
|
314
|
+
`.harness/task-pool/scheduler/evidence/${schedule.currentExecutionId}`,
|
|
315
|
+
],
|
|
316
|
+
}
|
|
317
|
+
: {}),
|
|
318
|
+
now,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
ok: true,
|
|
323
|
+
scheduleId: schedule.id,
|
|
324
|
+
worktreeRemoved,
|
|
325
|
+
branchDeleted,
|
|
326
|
+
mergeState: "discarded",
|
|
327
|
+
reason: input.reason,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
function resolveSafeNightWorktreePath(controlRepoRoot, worktreeRef) {
|
|
331
|
+
if (path.isAbsolute(worktreeRef)) {
|
|
332
|
+
throw new Error(`night worktree path must be repo-relative: ${worktreeRef}`);
|
|
333
|
+
}
|
|
334
|
+
const worktreeRoot = path.resolve(controlRepoRoot, ".worktrees");
|
|
335
|
+
const candidate = path.resolve(controlRepoRoot, worktreeRef);
|
|
336
|
+
const relative = path.relative(worktreeRoot, candidate);
|
|
337
|
+
if (relative.length === 0 ||
|
|
338
|
+
relative.startsWith("..") ||
|
|
339
|
+
path.isAbsolute(relative)) {
|
|
340
|
+
throw new Error(`night worktree path escapes .worktrees: ${worktreeRef}`);
|
|
341
|
+
}
|
|
342
|
+
return candidate;
|
|
343
|
+
}
|
|
344
|
+
function blocked(code, message, nextAction) {
|
|
345
|
+
return { ok: false, code, message, nextAction };
|
|
346
|
+
}
|
|
347
|
+
async function evidenceArchivePresent(controlRepoRoot, executionId) {
|
|
348
|
+
return (await verifyEvidenceArchive({ controlRepoRoot, executionId })).ok;
|
|
349
|
+
}
|
|
350
|
+
async function auditBranchTraceability(controlRepoRoot, baseCommit, nightBranch, schedule) {
|
|
351
|
+
const required = schedule.traceability?.requiredOnEveryPushableCommit ??
|
|
352
|
+
schedule.traceability?.requiredOnCreate ??
|
|
353
|
+
false;
|
|
354
|
+
if (!required)
|
|
355
|
+
return { ok: true };
|
|
356
|
+
const workItemRef = schedule.traceability?.workItemRef;
|
|
357
|
+
const card = validateTaskCard(workItemRef, {
|
|
358
|
+
required: true,
|
|
359
|
+
label: schedule.traceability?.label ?? "Task Card",
|
|
360
|
+
pattern: schedule.traceability?.pattern ?? "^[A-Z][A-Z0-9]+-[A-Z0-9]+#[0-9]+$",
|
|
361
|
+
commitMessageTemplate: schedule.traceability?.commitMessageTemplate ??
|
|
362
|
+
"{summary}\n\n{workItemRef}",
|
|
363
|
+
});
|
|
364
|
+
if (!card.ok || !card.workItemRef) {
|
|
365
|
+
return {
|
|
366
|
+
ok: false,
|
|
367
|
+
reason: card.ok ? "missing workItemRef" : card.reason,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
const log = (await runGit(controlRepoRoot, [
|
|
371
|
+
"log",
|
|
372
|
+
`${baseCommit}..${nightBranch}`,
|
|
373
|
+
"--format=%H%n%B%n==END==",
|
|
374
|
+
])).trim();
|
|
375
|
+
if (!log) {
|
|
376
|
+
return { ok: false, reason: "no commits on night branch after base" };
|
|
377
|
+
}
|
|
378
|
+
const messages = log
|
|
379
|
+
.split("==END==")
|
|
380
|
+
.map((part) => part.trim())
|
|
381
|
+
.filter(Boolean);
|
|
382
|
+
for (const block of messages) {
|
|
383
|
+
const body = block.split("\n").slice(1).join("\n");
|
|
384
|
+
if (!body.includes(card.workItemRef)) {
|
|
385
|
+
const sha = block.split("\n")[0] ?? "?";
|
|
386
|
+
return {
|
|
387
|
+
ok: false,
|
|
388
|
+
reason: `commit ${sha.slice(0, 12)} missing task card ${card.workItemRef}`,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return { ok: true };
|
|
393
|
+
}
|
|
394
|
+
async function runGit(cwd, args) {
|
|
395
|
+
const result = await runGitAllowFail(cwd, args);
|
|
396
|
+
if (result.exitCode !== 0) {
|
|
397
|
+
throw new Error(`git ${args.join(" ")} failed (exit ${result.exitCode}): ${result.stderr}`);
|
|
398
|
+
}
|
|
399
|
+
return result.stdout;
|
|
400
|
+
}
|
|
401
|
+
async function runGitAllowFail(cwd, args) {
|
|
402
|
+
return new Promise((resolve, reject) => {
|
|
403
|
+
const child = spawn("git", ["-C", cwd, ...args], {
|
|
404
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
405
|
+
});
|
|
406
|
+
const stdout = [];
|
|
407
|
+
const stderr = [];
|
|
408
|
+
child.stdout?.on("data", (c) => stdout.push(c));
|
|
409
|
+
child.stderr?.on("data", (c) => stderr.push(c));
|
|
410
|
+
child.on("error", reject);
|
|
411
|
+
child.on("close", (code) => {
|
|
412
|
+
resolve({
|
|
413
|
+
exitCode: code ?? 1,
|
|
414
|
+
stdout: Buffer.concat(stdout).toString("utf-8"),
|
|
415
|
+
stderr: Buffer.concat(stderr).toString("utf-8"),
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
// keep admission import used for future branch ownership checks
|
|
421
|
+
void readAdmission;
|