@tea-agent/loop-agent 0.28.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/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,84 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { getTaskPoolRoot } from "../pool/run-store.js";
|
|
3
|
+
import { SCHEDULER_ERROR_CODES, SchedulerError } from "./types.js";
|
|
4
|
+
export const SCHEDULER_RELATIVE_ROOT = ".harness/task-pool/scheduler";
|
|
5
|
+
const SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
6
|
+
export function assertSafeSchedulerId(value, label) {
|
|
7
|
+
if (typeof value !== "string" ||
|
|
8
|
+
value.length === 0 ||
|
|
9
|
+
!SAFE_SEGMENT.test(value)) {
|
|
10
|
+
throw new SchedulerError(SCHEDULER_ERROR_CODES.UNSAFE_ID, `${label} contains unsafe path characters: ${JSON.stringify(value)}`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function getSchedulerRoot(controlRepoRoot) {
|
|
14
|
+
return path.join(getTaskPoolRoot(controlRepoRoot), "scheduler");
|
|
15
|
+
}
|
|
16
|
+
export function getSchedulesDir(controlRepoRoot) {
|
|
17
|
+
return path.join(getSchedulerRoot(controlRepoRoot), "schedules");
|
|
18
|
+
}
|
|
19
|
+
export function getAdmissionsDir(controlRepoRoot) {
|
|
20
|
+
return path.join(getSchedulerRoot(controlRepoRoot), "admissions");
|
|
21
|
+
}
|
|
22
|
+
export function getExecutionsDir(controlRepoRoot) {
|
|
23
|
+
return path.join(getSchedulerRoot(controlRepoRoot), "executions");
|
|
24
|
+
}
|
|
25
|
+
export function getTransactionsDir(controlRepoRoot) {
|
|
26
|
+
return path.join(getSchedulerRoot(controlRepoRoot), "transactions");
|
|
27
|
+
}
|
|
28
|
+
export function getLeasesDir(controlRepoRoot) {
|
|
29
|
+
return path.join(getSchedulerRoot(controlRepoRoot), "leases");
|
|
30
|
+
}
|
|
31
|
+
export function getEvidenceDir(controlRepoRoot) {
|
|
32
|
+
return path.join(getSchedulerRoot(controlRepoRoot), "evidence");
|
|
33
|
+
}
|
|
34
|
+
export function getReportsDir(controlRepoRoot) {
|
|
35
|
+
return path.join(getSchedulerRoot(controlRepoRoot), "reports");
|
|
36
|
+
}
|
|
37
|
+
export function getLedgerPath(controlRepoRoot) {
|
|
38
|
+
return path.join(getSchedulerRoot(controlRepoRoot), "ledger.jsonl");
|
|
39
|
+
}
|
|
40
|
+
export function getMutationLockPath(controlRepoRoot) {
|
|
41
|
+
return path.join(getSchedulerRoot(controlRepoRoot), "mutation.lock");
|
|
42
|
+
}
|
|
43
|
+
export function getSchedulePath(controlRepoRoot, scheduleId) {
|
|
44
|
+
assertSafeSchedulerId(scheduleId, "scheduleId");
|
|
45
|
+
const schedulePath = path.join(getSchedulesDir(controlRepoRoot), `${scheduleId}.json`);
|
|
46
|
+
assertWithinSchedulerRoot(controlRepoRoot, schedulePath);
|
|
47
|
+
return schedulePath;
|
|
48
|
+
}
|
|
49
|
+
export function getAdmissionPath(controlRepoRoot, scheduleId) {
|
|
50
|
+
assertSafeSchedulerId(scheduleId, "scheduleId");
|
|
51
|
+
const admissionPath = path.join(getAdmissionsDir(controlRepoRoot), `${scheduleId}.json`);
|
|
52
|
+
assertWithinSchedulerRoot(controlRepoRoot, admissionPath);
|
|
53
|
+
return admissionPath;
|
|
54
|
+
}
|
|
55
|
+
export function getExecutionPath(controlRepoRoot, executionId) {
|
|
56
|
+
assertSafeSchedulerId(executionId, "executionId");
|
|
57
|
+
const executionPath = path.join(getExecutionsDir(controlRepoRoot), `${executionId}.json`);
|
|
58
|
+
assertWithinSchedulerRoot(controlRepoRoot, executionPath);
|
|
59
|
+
return executionPath;
|
|
60
|
+
}
|
|
61
|
+
export function getTransactionPath(controlRepoRoot, transitionId) {
|
|
62
|
+
assertSafeSchedulerId(transitionId, "transitionId");
|
|
63
|
+
const transactionPath = path.join(getTransactionsDir(controlRepoRoot), `${transitionId}.json`);
|
|
64
|
+
assertWithinSchedulerRoot(controlRepoRoot, transactionPath);
|
|
65
|
+
return transactionPath;
|
|
66
|
+
}
|
|
67
|
+
export function getLeasePath(controlRepoRoot, scheduleId) {
|
|
68
|
+
assertSafeSchedulerId(scheduleId, "scheduleId");
|
|
69
|
+
const leasePath = path.join(getLeasesDir(controlRepoRoot), `${scheduleId}.json`);
|
|
70
|
+
assertWithinSchedulerRoot(controlRepoRoot, leasePath);
|
|
71
|
+
return leasePath;
|
|
72
|
+
}
|
|
73
|
+
export function relativeSchedulerPath(controlRepoRoot, absolutePath) {
|
|
74
|
+
const relative = path.relative(path.resolve(controlRepoRoot), absolutePath);
|
|
75
|
+
return relative.split(path.sep).join("/");
|
|
76
|
+
}
|
|
77
|
+
function assertWithinSchedulerRoot(controlRepoRoot, candidatePath) {
|
|
78
|
+
const root = path.resolve(getSchedulerRoot(controlRepoRoot));
|
|
79
|
+
const resolved = path.resolve(candidatePath);
|
|
80
|
+
const relative = path.relative(root, resolved);
|
|
81
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
82
|
+
throw new SchedulerError(SCHEDULER_ERROR_CODES.UNSAFE_ID, `Scheduler path escapes scheduler root: ${candidatePath}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { revalidateNightWorkspace } from "./workspace-adapter.js";
|
|
5
|
+
const VERIFY_NODE_ID_RE = /(^|[-_])(hard-)?(soft-)?(re)?verify([-_]|$)|shell-verify|final-verify|frontend-.*verify|backend-.*verify|qa-.*verify/i;
|
|
6
|
+
const WRITERISH_NODE_ID_RE = /(implement|repair|writer|codegen|scaffold|edit-pi|frontend-implement|backend-implement)/i;
|
|
7
|
+
/**
|
|
8
|
+
* Pure: extract a candidate failed node id from attempt / report primaryFailure.
|
|
9
|
+
*/
|
|
10
|
+
export function extractFailedNodeId(attempt, extraPrimaryFailure) {
|
|
11
|
+
const primary = attempt?.result?.reportDecision?.primaryFailure ??
|
|
12
|
+
extraPrimaryFailure ??
|
|
13
|
+
attempt?.result
|
|
14
|
+
?.primaryFailure;
|
|
15
|
+
if (primary && typeof primary === "object" && !Array.isArray(primary)) {
|
|
16
|
+
const rec = primary;
|
|
17
|
+
for (const key of ["nodeId", "id", "fromNodeId", "taskId"]) {
|
|
18
|
+
const value = rec[key];
|
|
19
|
+
if (typeof value === "string" && value.trim())
|
|
20
|
+
return value.trim();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const message = attempt?.error ?? attempt?.run?.error;
|
|
24
|
+
if (typeof message === "string") {
|
|
25
|
+
const m = /node[:\s]+([A-Za-z0-9._-]+)/i.exec(message);
|
|
26
|
+
if (m?.[1])
|
|
27
|
+
return m[1];
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Scan a completed/active dag-run directory for the first ERROR verify-safe node.
|
|
33
|
+
*/
|
|
34
|
+
export async function extractFailedVerifyNodeFromDagRun(workspaceRoot, dagRunId) {
|
|
35
|
+
const candidates = [
|
|
36
|
+
path.join(workspaceRoot, ".harness", "dag-runs", "completed", dagRunId, "state.json"),
|
|
37
|
+
path.join(workspaceRoot, ".harness", "dag-runs", "completed", dagRunId, "run.json"),
|
|
38
|
+
path.join(workspaceRoot, ".harness", "dag-runs", "active", dagRunId, "state.json"),
|
|
39
|
+
];
|
|
40
|
+
for (const filePath of candidates) {
|
|
41
|
+
try {
|
|
42
|
+
const raw = JSON.parse(await readFile(filePath, "utf8"));
|
|
43
|
+
const fromPrimary = extractFailedNodeId(undefined, raw.primaryFailure);
|
|
44
|
+
if (fromPrimary && isVerifySafeNodeId(fromPrimary))
|
|
45
|
+
return fromPrimary;
|
|
46
|
+
const nodes = Array.isArray(raw.nodes) ? raw.nodes : [];
|
|
47
|
+
for (const node of nodes) {
|
|
48
|
+
const status = typeof node.status === "string" ? node.status : "";
|
|
49
|
+
const id = (typeof node.nodeId === "string" && node.nodeId) ||
|
|
50
|
+
(typeof node.id === "string" && node.id) ||
|
|
51
|
+
"";
|
|
52
|
+
const failureCategory = typeof node.failureCategory === "string" ? node.failureCategory : "";
|
|
53
|
+
if (id &&
|
|
54
|
+
isVerifySafeNodeId(id) &&
|
|
55
|
+
(status === "ERROR" ||
|
|
56
|
+
(failureCategory && failureCategory !== "success"))) {
|
|
57
|
+
return id;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// try next
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Pure heuristic: is this node id a verify/shell-style node that is safe to
|
|
69
|
+
* prefer for FlakyTest verify-only restart (not a business writer)?
|
|
70
|
+
*/
|
|
71
|
+
export function isVerifySafeNodeId(nodeId) {
|
|
72
|
+
const id = nodeId.trim();
|
|
73
|
+
if (!id)
|
|
74
|
+
return false;
|
|
75
|
+
if (WRITERISH_NODE_ID_RE.test(id))
|
|
76
|
+
return false;
|
|
77
|
+
return VERIFY_NODE_ID_RE.test(id);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Assess whether a terminal failed night attempt may re-enter waiting (full
|
|
81
|
+
* prepared re-attempt) or should try verify-only / stop.
|
|
82
|
+
*
|
|
83
|
+
* Does not re-generate DAG or expand writeSet. EnvFailure may full-reattempt
|
|
84
|
+
* only when admission/worktree are still bound. FlakyTest prefers verify-only.
|
|
85
|
+
*/
|
|
86
|
+
export async function assessPreparedAttemptRecovery(input) {
|
|
87
|
+
const category = input.failureCategory ?? "Unknown";
|
|
88
|
+
const reasons = [];
|
|
89
|
+
const terminationUnconfirmed = input.terminationUnconfirmed === true;
|
|
90
|
+
const maxAutoAttempts = input.schedule.policySnapshot?.maxAutoAttempts ?? 1;
|
|
91
|
+
if (terminationUnconfirmed) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
mode: "stop",
|
|
95
|
+
category,
|
|
96
|
+
reasons: ["termination-unconfirmed"],
|
|
97
|
+
terminationUnconfirmed: true,
|
|
98
|
+
admissionReusable: false,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (input.schedule.status !== "failed" &&
|
|
102
|
+
input.schedule.status !== "running") {
|
|
103
|
+
// running is allowed when assessing mid-dispatch before transition
|
|
104
|
+
if (input.schedule.status !== "dispatching") {
|
|
105
|
+
reasons.push(`status-not-retryable:${input.schedule.status}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (input.attemptCount >= maxAutoAttempts) {
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
mode: "stop",
|
|
112
|
+
category,
|
|
113
|
+
reasons: [
|
|
114
|
+
...reasons,
|
|
115
|
+
`budget-exhausted:${input.attemptCount}/${maxAutoAttempts}`,
|
|
116
|
+
],
|
|
117
|
+
terminationUnconfirmed: false,
|
|
118
|
+
admissionReusable: false,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const admission = input.admission;
|
|
122
|
+
if (!admission?.workspace ||
|
|
123
|
+
!admission.gateApproval?.token ||
|
|
124
|
+
!admission.dag) {
|
|
125
|
+
return {
|
|
126
|
+
ok: false,
|
|
127
|
+
mode: "stop",
|
|
128
|
+
category,
|
|
129
|
+
reasons: [...reasons, "admission-incomplete"],
|
|
130
|
+
terminationUnconfirmed: false,
|
|
131
|
+
admissionReusable: false,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
let admissionReusable = true;
|
|
135
|
+
const revalidate = input.revalidateWorkspace ?? revalidateNightWorkspace;
|
|
136
|
+
try {
|
|
137
|
+
await revalidate({
|
|
138
|
+
controlRepoRoot: input.controlRepoRoot,
|
|
139
|
+
scheduleId: input.schedule.id,
|
|
140
|
+
worktreePath: admission.workspace.path,
|
|
141
|
+
branch: admission.workspace.branch,
|
|
142
|
+
baseCommit: admission.workspace.baseCommit,
|
|
143
|
+
ownershipToken: admission.workspace.ownershipToken,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
admissionReusable = false;
|
|
148
|
+
reasons.push(`worktree-not-reusable:${error instanceof Error ? error.message : String(error)}`);
|
|
149
|
+
}
|
|
150
|
+
// Fail-closed categories never auto.
|
|
151
|
+
if (category === "ProductBug" ||
|
|
152
|
+
category === "TestBug" ||
|
|
153
|
+
category === "SpecUnclear" ||
|
|
154
|
+
category === "ContractMismatch" ||
|
|
155
|
+
category === "RiskyChange" ||
|
|
156
|
+
category === "NeedsHuman" ||
|
|
157
|
+
category === "Unknown" ||
|
|
158
|
+
category === "DependencyFailure") {
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
mode: "stop",
|
|
162
|
+
category,
|
|
163
|
+
reasons: [...reasons, `category-not-retryable:${category}`],
|
|
164
|
+
terminationUnconfirmed: false,
|
|
165
|
+
admissionReusable,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
if (category === "FlakyTest") {
|
|
169
|
+
if (!admissionReusable) {
|
|
170
|
+
return {
|
|
171
|
+
ok: false,
|
|
172
|
+
mode: "stop",
|
|
173
|
+
category,
|
|
174
|
+
reasons: [...reasons, "admission-not-reusable-for-verify-only"],
|
|
175
|
+
terminationUnconfirmed: false,
|
|
176
|
+
admissionReusable: false,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const parentDagRunId = input.lastExecution?.dagRunId ??
|
|
180
|
+
(input.workspaceRoot
|
|
181
|
+
? await resolveLatestCompletedDagRunId(input.workspaceRoot, admission.harnessTaskId)
|
|
182
|
+
: undefined);
|
|
183
|
+
let fromNodeId = extractFailedNodeId(input.attempt);
|
|
184
|
+
if ((!fromNodeId || !isVerifySafeNodeId(fromNodeId)) &&
|
|
185
|
+
parentDagRunId &&
|
|
186
|
+
input.workspaceRoot) {
|
|
187
|
+
fromNodeId =
|
|
188
|
+
(await extractFailedVerifyNodeFromDagRun(input.workspaceRoot, parentDagRunId)) ?? fromNodeId;
|
|
189
|
+
}
|
|
190
|
+
if (!fromNodeId || !isVerifySafeNodeId(fromNodeId)) {
|
|
191
|
+
return {
|
|
192
|
+
ok: false,
|
|
193
|
+
mode: "stop",
|
|
194
|
+
category,
|
|
195
|
+
reasons: [
|
|
196
|
+
...reasons,
|
|
197
|
+
fromNodeId
|
|
198
|
+
? `flaky-node-not-verify-safe:${fromNodeId}`
|
|
199
|
+
: "flaky-node-unresolved",
|
|
200
|
+
],
|
|
201
|
+
terminationUnconfirmed: false,
|
|
202
|
+
admissionReusable,
|
|
203
|
+
...(fromNodeId ? { fromNodeId } : {}),
|
|
204
|
+
...(parentDagRunId ? { parentDagRunId } : {}),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
if (!parentDagRunId) {
|
|
208
|
+
return {
|
|
209
|
+
ok: false,
|
|
210
|
+
mode: "stop",
|
|
211
|
+
category,
|
|
212
|
+
reasons: [...reasons, "parent-dag-run-unresolved"],
|
|
213
|
+
fromNodeId,
|
|
214
|
+
terminationUnconfirmed: false,
|
|
215
|
+
admissionReusable,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
ok: true,
|
|
220
|
+
mode: "verify-only-rerun",
|
|
221
|
+
category,
|
|
222
|
+
reasons: [...reasons, "flaky-verify-only-candidate"],
|
|
223
|
+
fromNodeId,
|
|
224
|
+
parentDagRunId,
|
|
225
|
+
terminationUnconfirmed: false,
|
|
226
|
+
admissionReusable,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
if (category === "EnvFailure") {
|
|
230
|
+
if (!admissionReusable) {
|
|
231
|
+
return {
|
|
232
|
+
ok: false,
|
|
233
|
+
mode: "stop",
|
|
234
|
+
category,
|
|
235
|
+
reasons: [...reasons, "admission-not-reusable"],
|
|
236
|
+
terminationUnconfirmed: false,
|
|
237
|
+
admissionReusable: false,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
if (!input.workspaceRoot) {
|
|
241
|
+
return {
|
|
242
|
+
ok: false,
|
|
243
|
+
mode: "stop",
|
|
244
|
+
category,
|
|
245
|
+
reasons: [...reasons, "workspace-root-required-for-env-retry"],
|
|
246
|
+
terminationUnconfirmed: false,
|
|
247
|
+
admissionReusable: false,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
const inspect = input.inspectWorkspaceChanges ?? inspectGitWorkspaceChanges;
|
|
252
|
+
const changes = await inspect(input.workspaceRoot);
|
|
253
|
+
if (changes.length > 0) {
|
|
254
|
+
return {
|
|
255
|
+
ok: false,
|
|
256
|
+
mode: "stop",
|
|
257
|
+
category,
|
|
258
|
+
reasons: [
|
|
259
|
+
...reasons,
|
|
260
|
+
`writer-side-effects-present:${changes.slice(0, 5).join(",")}`,
|
|
261
|
+
],
|
|
262
|
+
terminationUnconfirmed: false,
|
|
263
|
+
admissionReusable: false,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
return {
|
|
269
|
+
ok: false,
|
|
270
|
+
mode: "stop",
|
|
271
|
+
category,
|
|
272
|
+
reasons: [
|
|
273
|
+
...reasons,
|
|
274
|
+
`workspace-status-unavailable:${error instanceof Error ? error.message : String(error)}`,
|
|
275
|
+
],
|
|
276
|
+
terminationUnconfirmed: false,
|
|
277
|
+
admissionReusable: false,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
ok: true,
|
|
282
|
+
mode: "full-prepared-reattempt",
|
|
283
|
+
category,
|
|
284
|
+
reasons: [...reasons, "env-failure-prepared-reattempt"],
|
|
285
|
+
terminationUnconfirmed: false,
|
|
286
|
+
admissionReusable: true,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
ok: false,
|
|
291
|
+
mode: "stop",
|
|
292
|
+
category,
|
|
293
|
+
reasons: [...reasons, `category-not-retryable:${category}`],
|
|
294
|
+
terminationUnconfirmed: false,
|
|
295
|
+
admissionReusable,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Plan + execute a verify-only dag rerun via published loop-agent CLI
|
|
300
|
+
* (cwd = workspace). Refuses when plan is ineligible or contains writers.
|
|
301
|
+
*/
|
|
302
|
+
async function inspectGitWorkspaceChanges(workspaceRoot) {
|
|
303
|
+
return new Promise((resolve, reject) => {
|
|
304
|
+
const child = spawn("git", ["-C", workspaceRoot, "status", "--porcelain=v1", "--untracked-files=all"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
305
|
+
const stdout = [];
|
|
306
|
+
const stderr = [];
|
|
307
|
+
child.stdout?.on("data", (chunk) => stdout.push(chunk));
|
|
308
|
+
child.stderr?.on("data", (chunk) => stderr.push(chunk));
|
|
309
|
+
child.on("error", reject);
|
|
310
|
+
child.on("close", (code) => {
|
|
311
|
+
if (code !== 0) {
|
|
312
|
+
reject(new Error(`git status failed (exit ${code ?? "null"}): ${Buffer.concat(stderr).toString("utf-8").trim()}`));
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
resolve(Buffer.concat(stdout)
|
|
316
|
+
.toString("utf-8")
|
|
317
|
+
.split(/\r?\n/)
|
|
318
|
+
.map((line) => line.trim())
|
|
319
|
+
.filter(Boolean));
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
export async function runVerifyOnlyRerun(input) {
|
|
324
|
+
const planArgs = [
|
|
325
|
+
"dag",
|
|
326
|
+
"rerun",
|
|
327
|
+
"--run-id",
|
|
328
|
+
input.parentDagRunId,
|
|
329
|
+
"--from-node",
|
|
330
|
+
input.fromNodeId,
|
|
331
|
+
"--plan",
|
|
332
|
+
"--json",
|
|
333
|
+
];
|
|
334
|
+
const planResult = await input.client.run(planArgs, {
|
|
335
|
+
cwd: input.workspaceRoot,
|
|
336
|
+
artifactName: `night-verify-plan-${input.requestId}`,
|
|
337
|
+
expectJson: true,
|
|
338
|
+
timeoutMs: 120_000,
|
|
339
|
+
});
|
|
340
|
+
const planJson = asRecord(planResult.json);
|
|
341
|
+
const plan = asRecord(planJson?.plan) ?? planJson;
|
|
342
|
+
const eligible = plan?.eligible === true || planJson?.ok === true;
|
|
343
|
+
const reasonCodes = asStringArray(plan?.reasonCodes);
|
|
344
|
+
const blockedReasons = asStringArray(plan?.blockedReasons);
|
|
345
|
+
const planHash = typeof plan?.planHash === "string" ? plan.planHash : undefined;
|
|
346
|
+
const risk = typeof plan?.risk === "string" ? plan.risk : undefined;
|
|
347
|
+
if (!eligible || !planHash) {
|
|
348
|
+
return {
|
|
349
|
+
ok: false,
|
|
350
|
+
reason: "verify-plan-ineligible",
|
|
351
|
+
detail: [...reasonCodes, ...blockedReasons].join(",") || "no planHash",
|
|
352
|
+
parentDagRunId: input.parentDagRunId,
|
|
353
|
+
fromNodeId: input.fromNodeId,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
if (reasonCodes.includes("restart-subgraph-contains-writer") ||
|
|
357
|
+
risk === "high") {
|
|
358
|
+
return {
|
|
359
|
+
ok: false,
|
|
360
|
+
reason: "verify-plan-contains-writer-or-high-risk",
|
|
361
|
+
detail: reasonCodes.join(",") || risk,
|
|
362
|
+
parentDagRunId: input.parentDagRunId,
|
|
363
|
+
fromNodeId: input.fromNodeId,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
const execArgs = [
|
|
367
|
+
"dag",
|
|
368
|
+
"rerun",
|
|
369
|
+
"--run-id",
|
|
370
|
+
input.parentDagRunId,
|
|
371
|
+
"--from-node",
|
|
372
|
+
input.fromNodeId,
|
|
373
|
+
"--plan-hash",
|
|
374
|
+
planHash,
|
|
375
|
+
"--request-id",
|
|
376
|
+
input.requestId,
|
|
377
|
+
"--reason",
|
|
378
|
+
input.reason ?? "night-scheduler-flakytest-verify-only",
|
|
379
|
+
"--json",
|
|
380
|
+
];
|
|
381
|
+
const execResult = await input.client.run(execArgs, {
|
|
382
|
+
cwd: input.workspaceRoot,
|
|
383
|
+
artifactName: `night-verify-exec-${input.requestId}`,
|
|
384
|
+
expectJson: true,
|
|
385
|
+
// Verify-only may still take a while; allow long wall clock.
|
|
386
|
+
timeoutMs: 0,
|
|
387
|
+
});
|
|
388
|
+
if (!execResult.ok && execResult.exitCode !== 0) {
|
|
389
|
+
return {
|
|
390
|
+
ok: false,
|
|
391
|
+
reason: "verify-exec-failed",
|
|
392
|
+
detail: execResult.stderr?.slice(0, 500) ||
|
|
393
|
+
`exit ${execResult.exitCode ?? "null"}`,
|
|
394
|
+
parentDagRunId: input.parentDagRunId,
|
|
395
|
+
fromNodeId: input.fromNodeId,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
const execJson = asRecord(execResult.json);
|
|
399
|
+
const childRunId = (typeof execJson?.runId === "string" && execJson.runId) ||
|
|
400
|
+
(typeof execJson?.childRunId === "string" && execJson.childRunId) ||
|
|
401
|
+
undefined;
|
|
402
|
+
return {
|
|
403
|
+
ok: true,
|
|
404
|
+
parentDagRunId: input.parentDagRunId,
|
|
405
|
+
fromNodeId: input.fromNodeId,
|
|
406
|
+
planHash,
|
|
407
|
+
...(childRunId ? { childRunId } : {}),
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Resolve newest completed dag-run id under workspace for a harness task.
|
|
412
|
+
*/
|
|
413
|
+
export async function resolveLatestCompletedDagRunId(workspaceRoot, harnessTaskId) {
|
|
414
|
+
if (!harnessTaskId)
|
|
415
|
+
return undefined;
|
|
416
|
+
const completedDir = path.join(workspaceRoot, ".harness", "dag-runs", "completed");
|
|
417
|
+
let entries = [];
|
|
418
|
+
try {
|
|
419
|
+
entries = await readdir(completedDir);
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
const matches = [];
|
|
425
|
+
for (const name of entries) {
|
|
426
|
+
if (!name || name === ".gitkeep")
|
|
427
|
+
continue;
|
|
428
|
+
const runDir = path.join(completedDir, name);
|
|
429
|
+
let mtimeMs = 0;
|
|
430
|
+
try {
|
|
431
|
+
mtimeMs = (await stat(runDir)).mtimeMs;
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
const taskId = await readTaskIdFromRunDir(runDir);
|
|
437
|
+
if (taskId === harnessTaskId) {
|
|
438
|
+
matches.push({ id: name, mtimeMs });
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
matches.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
442
|
+
return matches[0]?.id;
|
|
443
|
+
}
|
|
444
|
+
async function readTaskIdFromRunDir(runDir) {
|
|
445
|
+
try {
|
|
446
|
+
const raw = JSON.parse(await readFile(path.join(runDir, "run.json"), "utf8"));
|
|
447
|
+
const fromBinding = raw.taskContractBinding?.taskId?.trim() ||
|
|
448
|
+
raw.sourceBinding?.taskId?.trim();
|
|
449
|
+
if (fromBinding)
|
|
450
|
+
return fromBinding;
|
|
451
|
+
const title = raw.title?.trim() ?? "";
|
|
452
|
+
const hybrid = /^Hybrid DAG:\s*(.+)$/i.exec(title);
|
|
453
|
+
if (hybrid?.[1]?.trim())
|
|
454
|
+
return hybrid[1].trim();
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
// ignore
|
|
458
|
+
}
|
|
459
|
+
return undefined;
|
|
460
|
+
}
|
|
461
|
+
function asRecord(value) {
|
|
462
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
463
|
+
return undefined;
|
|
464
|
+
}
|
|
465
|
+
return value;
|
|
466
|
+
}
|
|
467
|
+
function asStringArray(value) {
|
|
468
|
+
if (!Array.isArray(value))
|
|
469
|
+
return [];
|
|
470
|
+
return value.filter((item) => typeof item === "string");
|
|
471
|
+
}
|