@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,277 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { readExecutionLease, releaseExecutionLease, } from "./lease.js";
|
|
3
|
+
import { transitionSchedule } from "./lifecycle.js";
|
|
4
|
+
import { getLeasesDir } from "./paths.js";
|
|
5
|
+
import { listExecutionsForSchedule, listSchedules, readExecution, writeExecution, } from "./store.js";
|
|
6
|
+
function isPidAlive(pid) {
|
|
7
|
+
if (!Number.isFinite(pid) || pid <= 0)
|
|
8
|
+
return false;
|
|
9
|
+
try {
|
|
10
|
+
process.kill(pid, 0);
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function classifyLeaseLiveness(lease, now = new Date(), staleMs = 5 * 60_000) {
|
|
18
|
+
if (!lease)
|
|
19
|
+
return "missing";
|
|
20
|
+
const heartbeatMs = Date.parse(lease.heartbeatAt);
|
|
21
|
+
const heartbeatStale = !Number.isFinite(heartbeatMs) || now.getTime() - heartbeatMs > staleMs;
|
|
22
|
+
const pidAlive = isPidAlive(lease.ownerPid);
|
|
23
|
+
if (!heartbeatStale && pidAlive)
|
|
24
|
+
return "active";
|
|
25
|
+
if (!pidAlive)
|
|
26
|
+
return "stale-dead-pid";
|
|
27
|
+
return "stale-heartbeat";
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Classify whether a non-terminal schedule with a lease/execution can be
|
|
31
|
+
* safely reclaimed or must go human_required (termination-unconfirmed).
|
|
32
|
+
*
|
|
33
|
+
* Rules (ADR 0009 / Phase 4):
|
|
34
|
+
* - No workerRunId and not running → safe reclaim (crash after claim, before child)
|
|
35
|
+
* - workerRunId or running/dispatching with dead owner → termination-unconfirmed
|
|
36
|
+
* (DAG may still be alive; never start a second writer automatically)
|
|
37
|
+
*/
|
|
38
|
+
export function classifyLeaseRecovery(input) {
|
|
39
|
+
const now = input.now ?? new Date();
|
|
40
|
+
const liveness = classifyLeaseLiveness(input.lease, now, input.staleMs);
|
|
41
|
+
const schedule = input.schedule;
|
|
42
|
+
const execution = input.execution;
|
|
43
|
+
if (schedule.status === "succeeded" ||
|
|
44
|
+
schedule.status === "failed" ||
|
|
45
|
+
schedule.status === "human_required" ||
|
|
46
|
+
schedule.status === "cancelled" ||
|
|
47
|
+
schedule.status === "rejected") {
|
|
48
|
+
if (input.lease) {
|
|
49
|
+
return {
|
|
50
|
+
kind: "already-terminal",
|
|
51
|
+
reason: `schedule terminal (${schedule.status}) but lease file remains`,
|
|
52
|
+
...(execution ? { execution } : {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return { kind: "none", reason: "schedule-terminal" };
|
|
56
|
+
}
|
|
57
|
+
if (!input.lease && !execution) {
|
|
58
|
+
return { kind: "none", reason: "no-lease-or-execution" };
|
|
59
|
+
}
|
|
60
|
+
if (execution && isExecutionTerminal(execution.status)) {
|
|
61
|
+
return {
|
|
62
|
+
kind: "already-terminal",
|
|
63
|
+
reason: `execution already terminal (${execution.status})`,
|
|
64
|
+
execution,
|
|
65
|
+
...(input.lease ? {} : {}),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const workerAssociated = Boolean(execution?.workerRunId || input.lease?.workerRunId);
|
|
69
|
+
const inFlight = schedule.status === "running" ||
|
|
70
|
+
schedule.status === "dispatching" ||
|
|
71
|
+
schedule.status === "cancel_requested" ||
|
|
72
|
+
execution?.status === "running" ||
|
|
73
|
+
execution?.status === "claimed";
|
|
74
|
+
if (liveness === "active") {
|
|
75
|
+
return {
|
|
76
|
+
kind: "none",
|
|
77
|
+
reason: "lease-active",
|
|
78
|
+
...(input.lease ? { lease: input.lease } : {}),
|
|
79
|
+
...(execution ? { execution } : {}),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// Stale or missing owner.
|
|
83
|
+
if (!workerAssociated && execution?.status === "claimed") {
|
|
84
|
+
return {
|
|
85
|
+
kind: "safe-reclaim",
|
|
86
|
+
reason: "orphan-claim-before-child",
|
|
87
|
+
...(input.lease ? { lease: input.lease } : {}),
|
|
88
|
+
execution,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (!workerAssociated && schedule.status === "dispatching" && !execution) {
|
|
92
|
+
return {
|
|
93
|
+
kind: "safe-reclaim",
|
|
94
|
+
reason: "orphan-claim-before-child",
|
|
95
|
+
...(input.lease ? { lease: input.lease } : {}),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (inFlight || workerAssociated) {
|
|
99
|
+
return {
|
|
100
|
+
kind: "termination-unconfirmed",
|
|
101
|
+
reason: workerAssociated
|
|
102
|
+
? "worker-or-dag-associated-but-owner-stale"
|
|
103
|
+
: "in-flight-without-confirmed-terminal",
|
|
104
|
+
...(input.lease ? { lease: input.lease } : {}),
|
|
105
|
+
...(execution ? { execution } : {}),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
kind: "none",
|
|
110
|
+
reason: `no-recovery-action (liveness=${liveness}, status=${schedule.status})`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function isExecutionTerminal(status) {
|
|
114
|
+
return (status === "succeeded" ||
|
|
115
|
+
status === "failed" ||
|
|
116
|
+
status === "human_required" ||
|
|
117
|
+
status === "cancelled");
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Scan schedules/leases and apply safe recovery:
|
|
121
|
+
* - release orphan claims (re-queue to waiting)
|
|
122
|
+
* - mark termination-unconfirmed as human_required
|
|
123
|
+
* - drop leftover leases on terminal schedules
|
|
124
|
+
*/
|
|
125
|
+
export async function recoverStaleSchedulerState(input) {
|
|
126
|
+
const now = input.now ?? new Date();
|
|
127
|
+
const results = [];
|
|
128
|
+
const schedules = await listSchedules(input.controlRepoRoot);
|
|
129
|
+
// Also scan lease files that may not match loaded schedules.
|
|
130
|
+
const leaseScheduleIds = new Set();
|
|
131
|
+
try {
|
|
132
|
+
const names = await readdir(getLeasesDir(input.controlRepoRoot));
|
|
133
|
+
for (const name of names) {
|
|
134
|
+
if (name.endsWith(".json"))
|
|
135
|
+
leaseScheduleIds.add(name.slice(0, -5));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// no leases dir
|
|
140
|
+
}
|
|
141
|
+
for (const schedule of schedules)
|
|
142
|
+
leaseScheduleIds.add(schedule.id);
|
|
143
|
+
for (const scheduleId of leaseScheduleIds) {
|
|
144
|
+
const schedule = schedules.find((item) => item.id === scheduleId);
|
|
145
|
+
const lease = await readExecutionLease(input.controlRepoRoot, scheduleId);
|
|
146
|
+
let execution;
|
|
147
|
+
if (schedule?.currentExecutionId) {
|
|
148
|
+
execution = await readExecution(input.controlRepoRoot, schedule.currentExecutionId);
|
|
149
|
+
}
|
|
150
|
+
if (!execution && lease?.executionId) {
|
|
151
|
+
execution = await readExecution(input.controlRepoRoot, lease.executionId);
|
|
152
|
+
}
|
|
153
|
+
if (!schedule) {
|
|
154
|
+
if (lease) {
|
|
155
|
+
const liveness = classifyLeaseLiveness(lease, now, input.staleMs);
|
|
156
|
+
if (liveness !== "active") {
|
|
157
|
+
await releaseExecutionLease({
|
|
158
|
+
controlRepoRoot: input.controlRepoRoot,
|
|
159
|
+
scheduleId,
|
|
160
|
+
}).catch(() => { });
|
|
161
|
+
results.push({
|
|
162
|
+
scheduleId,
|
|
163
|
+
action: "released-stale-lease-on-terminal",
|
|
164
|
+
detail: "lease without schedule snapshot released",
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const classification = classifyLeaseRecovery({
|
|
171
|
+
schedule,
|
|
172
|
+
...(lease ? { lease } : {}),
|
|
173
|
+
...(execution ? { execution } : {}),
|
|
174
|
+
now,
|
|
175
|
+
...(input.staleMs !== undefined ? { staleMs: input.staleMs } : {}),
|
|
176
|
+
});
|
|
177
|
+
if (classification.kind === "already-terminal") {
|
|
178
|
+
if (lease) {
|
|
179
|
+
await releaseExecutionLease({
|
|
180
|
+
controlRepoRoot: input.controlRepoRoot,
|
|
181
|
+
scheduleId,
|
|
182
|
+
}).catch(() => { });
|
|
183
|
+
results.push({
|
|
184
|
+
scheduleId,
|
|
185
|
+
action: "released-stale-lease-on-terminal",
|
|
186
|
+
detail: classification.reason,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
results.push({
|
|
191
|
+
scheduleId,
|
|
192
|
+
action: "none",
|
|
193
|
+
detail: classification.reason,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (classification.kind === "safe-reclaim") {
|
|
199
|
+
if (execution && execution.status === "claimed") {
|
|
200
|
+
await writeExecution(input.controlRepoRoot, {
|
|
201
|
+
...execution,
|
|
202
|
+
status: "cancelled",
|
|
203
|
+
finishedAt: now.toISOString(),
|
|
204
|
+
failure: {
|
|
205
|
+
code: "orphan-claim-before-child",
|
|
206
|
+
message: "reclaimed after crash before worker association",
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
await releaseExecutionLease({
|
|
211
|
+
controlRepoRoot: input.controlRepoRoot,
|
|
212
|
+
scheduleId,
|
|
213
|
+
}).catch(() => { });
|
|
214
|
+
if (schedule.status === "dispatching" ||
|
|
215
|
+
schedule.status === "running" ||
|
|
216
|
+
schedule.status === "cancel_requested") {
|
|
217
|
+
await transitionSchedule({
|
|
218
|
+
controlRepoRoot: input.controlRepoRoot,
|
|
219
|
+
scheduleId,
|
|
220
|
+
toStatus: "waiting",
|
|
221
|
+
event: "lease_recovered",
|
|
222
|
+
reason: "orphan claim reclaimed; re-queue for next tick",
|
|
223
|
+
reasonCode: "orphan-claim-before-child",
|
|
224
|
+
patch: {
|
|
225
|
+
currentExecutionId: null,
|
|
226
|
+
waitingReason: "lease-recovered-orphan-claim",
|
|
227
|
+
nextAttemptAt: now.toISOString(),
|
|
228
|
+
},
|
|
229
|
+
now,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
results.push({
|
|
233
|
+
scheduleId,
|
|
234
|
+
action: "released-orphan-claim",
|
|
235
|
+
detail: classification.reason,
|
|
236
|
+
});
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (classification.kind === "termination-unconfirmed") {
|
|
240
|
+
if (schedule.status !== "human_required" &&
|
|
241
|
+
schedule.status !== "failed" &&
|
|
242
|
+
schedule.status !== "cancelled" &&
|
|
243
|
+
schedule.status !== "succeeded") {
|
|
244
|
+
await transitionSchedule({
|
|
245
|
+
controlRepoRoot: input.controlRepoRoot,
|
|
246
|
+
scheduleId,
|
|
247
|
+
toStatus: "human_required",
|
|
248
|
+
event: "human_required",
|
|
249
|
+
reason: classification.reason,
|
|
250
|
+
reasonCode: "termination-unconfirmed",
|
|
251
|
+
now,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
// Do NOT steal lease / do NOT start second writer.
|
|
255
|
+
results.push({
|
|
256
|
+
scheduleId,
|
|
257
|
+
action: "human-required-unconfirmed",
|
|
258
|
+
detail: classification.reason,
|
|
259
|
+
});
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
results.push({
|
|
263
|
+
scheduleId,
|
|
264
|
+
action: "none",
|
|
265
|
+
detail: classification.reason,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
return results;
|
|
269
|
+
}
|
|
270
|
+
export async function countAttemptsForSchedule(controlRepoRoot, scheduleId) {
|
|
271
|
+
const executions = await listExecutionsForSchedule(controlRepoRoot, scheduleId);
|
|
272
|
+
return executions.length;
|
|
273
|
+
}
|
|
274
|
+
/** Test helper re-export surface for PID checks without exporting isPidAlive. */
|
|
275
|
+
export function leaseOwnerLooksAlive(lease) {
|
|
276
|
+
return isPidAlive(lease.ownerPid);
|
|
277
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { readTaskPoolState, writeTaskPoolState } from "../pool/run-store.js";
|
|
2
|
+
import { SCHEDULER_ERROR_CODES, SchedulerError } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Project schedule reservation onto Task Pool: Ready → Queued with night fields.
|
|
5
|
+
* Fail-closed if another schedule already owns the reservation or status is not Ready/Queued(same id).
|
|
6
|
+
*/
|
|
7
|
+
export async function reserveTaskPoolForSchedule(input) {
|
|
8
|
+
const now = (input.now ?? new Date()).toISOString();
|
|
9
|
+
const ref = { featureId: input.featureId, taskId: input.taskId };
|
|
10
|
+
const current = await readTaskPoolState(input.controlRepoRoot, ref);
|
|
11
|
+
if (!current) {
|
|
12
|
+
// Create Queued projection when no pool state exists yet (admission-created schedule).
|
|
13
|
+
const created = {
|
|
14
|
+
schemaVersion: 2,
|
|
15
|
+
featureId: input.featureId,
|
|
16
|
+
taskId: input.taskId,
|
|
17
|
+
status: "Queued",
|
|
18
|
+
updatedAt: now,
|
|
19
|
+
nightScheduleId: input.nightScheduleId,
|
|
20
|
+
admissionPath: input.admissionPath,
|
|
21
|
+
nightWorktreePath: input.nightWorktreePath,
|
|
22
|
+
nightBranch: input.nightBranch,
|
|
23
|
+
};
|
|
24
|
+
await writeTaskPoolState(input.controlRepoRoot, created);
|
|
25
|
+
return created;
|
|
26
|
+
}
|
|
27
|
+
if (current.nightScheduleId &&
|
|
28
|
+
current.nightScheduleId !== input.nightScheduleId) {
|
|
29
|
+
throw new SchedulerError(SCHEDULER_ERROR_CODES.DUPLICATE_ACTIVE, `Task Pool ${input.featureId}/${input.taskId} already reserved by ${current.nightScheduleId}`);
|
|
30
|
+
}
|
|
31
|
+
if (current.status === "Queued" &&
|
|
32
|
+
current.nightScheduleId === input.nightScheduleId) {
|
|
33
|
+
const refreshed = {
|
|
34
|
+
...current,
|
|
35
|
+
schemaVersion: 2,
|
|
36
|
+
featureId: input.featureId,
|
|
37
|
+
taskId: input.taskId,
|
|
38
|
+
status: "Queued",
|
|
39
|
+
updatedAt: now,
|
|
40
|
+
nightScheduleId: input.nightScheduleId,
|
|
41
|
+
admissionPath: input.admissionPath,
|
|
42
|
+
nightWorktreePath: input.nightWorktreePath,
|
|
43
|
+
nightBranch: input.nightBranch,
|
|
44
|
+
};
|
|
45
|
+
await writeTaskPoolState(input.controlRepoRoot, refreshed);
|
|
46
|
+
return refreshed;
|
|
47
|
+
}
|
|
48
|
+
if (current.status !== "Ready") {
|
|
49
|
+
throw new SchedulerError(SCHEDULER_ERROR_CODES.INVALID_TRANSITION, `cannot reserve Task Pool ${input.featureId}/${input.taskId} from status ${current.status}`);
|
|
50
|
+
}
|
|
51
|
+
const next = {
|
|
52
|
+
...current,
|
|
53
|
+
schemaVersion: 2,
|
|
54
|
+
featureId: input.featureId,
|
|
55
|
+
taskId: input.taskId,
|
|
56
|
+
status: "Queued",
|
|
57
|
+
updatedAt: now,
|
|
58
|
+
nightScheduleId: input.nightScheduleId,
|
|
59
|
+
admissionPath: input.admissionPath,
|
|
60
|
+
nightWorktreePath: input.nightWorktreePath,
|
|
61
|
+
nightBranch: input.nightBranch,
|
|
62
|
+
};
|
|
63
|
+
await writeTaskPoolState(input.controlRepoRoot, next);
|
|
64
|
+
return next;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Restore a failed night-owned Task Pool projection to Queued for a bounded
|
|
68
|
+
* prepared re-attempt. Refuses to steal another schedule or non-failed state.
|
|
69
|
+
*/
|
|
70
|
+
export async function requeueTaskPoolReservation(input) {
|
|
71
|
+
const now = (input.now ?? new Date()).toISOString();
|
|
72
|
+
const current = await readTaskPoolState(input.controlRepoRoot, {
|
|
73
|
+
featureId: input.featureId,
|
|
74
|
+
taskId: input.taskId,
|
|
75
|
+
});
|
|
76
|
+
if (!current) {
|
|
77
|
+
throw new SchedulerError(SCHEDULER_ERROR_CODES.INVALID_RECORD, `cannot requeue missing Task Pool ${input.featureId}/${input.taskId}`);
|
|
78
|
+
}
|
|
79
|
+
if (current.nightScheduleId !== input.nightScheduleId) {
|
|
80
|
+
throw new SchedulerError(SCHEDULER_ERROR_CODES.DUPLICATE_ACTIVE, `cannot requeue Task Pool ${input.featureId}/${input.taskId}: reservation is not owned by ${input.nightScheduleId}`);
|
|
81
|
+
}
|
|
82
|
+
if (current.status !== "Failed") {
|
|
83
|
+
throw new SchedulerError(SCHEDULER_ERROR_CODES.INVALID_TRANSITION, `cannot requeue Task Pool ${input.featureId}/${input.taskId} from status ${current.status}`);
|
|
84
|
+
}
|
|
85
|
+
const next = {
|
|
86
|
+
...current,
|
|
87
|
+
status: "Queued",
|
|
88
|
+
updatedAt: now,
|
|
89
|
+
};
|
|
90
|
+
await writeTaskPoolState(input.controlRepoRoot, next);
|
|
91
|
+
return next;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Release Queued reservation when cancelling before dispatch.
|
|
95
|
+
* Only restores Ready when nightScheduleId still matches.
|
|
96
|
+
*/
|
|
97
|
+
export async function releaseTaskPoolReservation(input) {
|
|
98
|
+
const now = (input.now ?? new Date()).toISOString();
|
|
99
|
+
const ref = { featureId: input.featureId, taskId: input.taskId };
|
|
100
|
+
const current = await readTaskPoolState(input.controlRepoRoot, ref);
|
|
101
|
+
if (!current)
|
|
102
|
+
return undefined;
|
|
103
|
+
if (current.nightScheduleId !== input.nightScheduleId) {
|
|
104
|
+
return current;
|
|
105
|
+
}
|
|
106
|
+
if (current.status !== "Queued") {
|
|
107
|
+
// Clear night fields but do not force Ready if already Running/Done/Failed.
|
|
108
|
+
const cleared = {
|
|
109
|
+
...current,
|
|
110
|
+
schemaVersion: 2,
|
|
111
|
+
updatedAt: now,
|
|
112
|
+
nightScheduleId: undefined,
|
|
113
|
+
admissionPath: undefined,
|
|
114
|
+
nightWorktreePath: undefined,
|
|
115
|
+
nightBranch: undefined,
|
|
116
|
+
};
|
|
117
|
+
// Explicitly omit night fields by rewriting without them.
|
|
118
|
+
const { nightScheduleId: _a, admissionPath: _b, nightWorktreePath: _c, nightBranch: _d, ...rest } = cleared;
|
|
119
|
+
void _a;
|
|
120
|
+
void _b;
|
|
121
|
+
void _c;
|
|
122
|
+
void _d;
|
|
123
|
+
await writeTaskPoolState(input.controlRepoRoot, {
|
|
124
|
+
...rest,
|
|
125
|
+
schemaVersion: 2,
|
|
126
|
+
featureId: input.featureId,
|
|
127
|
+
taskId: input.taskId,
|
|
128
|
+
status: current.status,
|
|
129
|
+
updatedAt: now,
|
|
130
|
+
});
|
|
131
|
+
return rest;
|
|
132
|
+
}
|
|
133
|
+
const next = {
|
|
134
|
+
schemaVersion: 2,
|
|
135
|
+
featureId: input.featureId,
|
|
136
|
+
taskId: input.taskId,
|
|
137
|
+
status: "Ready",
|
|
138
|
+
updatedAt: now,
|
|
139
|
+
...(current.workerRunId ? { workerRunId: current.workerRunId } : {}),
|
|
140
|
+
...(current.retryOfWorkerRunId
|
|
141
|
+
? { retryOfWorkerRunId: current.retryOfWorkerRunId }
|
|
142
|
+
: {}),
|
|
143
|
+
};
|
|
144
|
+
await writeTaskPoolState(input.controlRepoRoot, next);
|
|
145
|
+
return next;
|
|
146
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { listExecutionsForSchedule, readAdmission, readSchedule, } from "./store.js";
|
|
2
|
+
import { transitionSchedule } from "./lifecycle.js";
|
|
3
|
+
import { requeueTaskPoolReservation } from "./reservation.js";
|
|
4
|
+
import { assessPreparedAttemptRecovery, } from "./prepared-attempt-recovery.js";
|
|
5
|
+
import { SCHEDULER_ERROR_CODES, SchedulerError } from "./types.js";
|
|
6
|
+
/** Categories eligible for any Scheduler-level auto recovery (Phase 4 complete). */
|
|
7
|
+
export const AUTO_RETRY_FAILURE_CATEGORIES = new Set([
|
|
8
|
+
"EnvFailure",
|
|
9
|
+
"FlakyTest",
|
|
10
|
+
]);
|
|
11
|
+
/**
|
|
12
|
+
* Pure decision (legacy/simple path): whether a failed schedule may be
|
|
13
|
+
* re-queued without adapter I/O. Prefer assessAndDecideAutoRetry for
|
|
14
|
+
* full recovery-adapter semantics.
|
|
15
|
+
*/
|
|
16
|
+
export function decideAutoRetry(input) {
|
|
17
|
+
if (input.schedule.status !== "failed") {
|
|
18
|
+
return {
|
|
19
|
+
ok: false,
|
|
20
|
+
action: "stop",
|
|
21
|
+
reason: "not-failed",
|
|
22
|
+
detail: `status is ${input.schedule.status}`,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
if (input.terminationUnconfirmed) {
|
|
26
|
+
return {
|
|
27
|
+
ok: false,
|
|
28
|
+
action: "stop",
|
|
29
|
+
reason: "termination-unconfirmed",
|
|
30
|
+
detail: "refusing auto retry while prior attempt termination is unconfirmed",
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const maxAutoAttempts = input.schedule.policySnapshot?.maxAutoAttempts ?? 1;
|
|
34
|
+
if (input.attemptCount >= maxAutoAttempts) {
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
action: "stop",
|
|
38
|
+
reason: "budget-exhausted",
|
|
39
|
+
detail: `attempts=${input.attemptCount} maxAutoAttempts=${maxAutoAttempts}`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const category = input.failureCategory ?? "Unknown";
|
|
43
|
+
if (input.recoveryMode === "stop") {
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
action: "stop",
|
|
47
|
+
reason: "recovery-adapter-stop",
|
|
48
|
+
detail: `recovery adapter refused category ${category}`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
if (input.recoveryMode === "verify-only-rerun") {
|
|
52
|
+
return {
|
|
53
|
+
ok: true,
|
|
54
|
+
action: "verify-only",
|
|
55
|
+
mode: "verify-only-rerun",
|
|
56
|
+
attempt: input.attemptCount + 1,
|
|
57
|
+
maxAutoAttempts,
|
|
58
|
+
category,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (input.recoveryMode === "full-prepared-reattempt") {
|
|
62
|
+
return {
|
|
63
|
+
ok: true,
|
|
64
|
+
action: "queue-waiting",
|
|
65
|
+
mode: "full-prepared-reattempt",
|
|
66
|
+
attempt: input.attemptCount + 1,
|
|
67
|
+
maxAutoAttempts,
|
|
68
|
+
category,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
// Fallback without adapter: EnvFailure only (legacy Phase 4.0 behaviour).
|
|
72
|
+
if (!AUTO_RETRY_FAILURE_CATEGORIES.has(category)) {
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
action: "stop",
|
|
76
|
+
reason: "category-not-retryable",
|
|
77
|
+
detail: `category ${category} is not in EnvFailure/FlakyTest allowlist`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (category === "FlakyTest") {
|
|
81
|
+
// Without adapter assessment we cannot prove verify-safe node → stop.
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
action: "stop",
|
|
85
|
+
reason: "flaky-verify-unsafe",
|
|
86
|
+
detail: "FlakyTest requires recovery adapter verify-only assessment (missing)",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
ok: true,
|
|
91
|
+
action: "queue-waiting",
|
|
92
|
+
mode: "full-prepared-reattempt",
|
|
93
|
+
attempt: input.attemptCount + 1,
|
|
94
|
+
maxAutoAttempts,
|
|
95
|
+
category,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Full recovery-adapter path: assess admission/worktree + category policy,
|
|
100
|
+
* then optionally re-queue waiting for full prepared re-attempt.
|
|
101
|
+
* Verify-only decisions are returned without transitioning (dispatcher runs them).
|
|
102
|
+
*/
|
|
103
|
+
export async function assessAndDecideAutoRetry(input) {
|
|
104
|
+
const schedule = await readSchedule(input.controlRepoRoot, input.scheduleId);
|
|
105
|
+
if (!schedule) {
|
|
106
|
+
throw new SchedulerError(SCHEDULER_ERROR_CODES.NOT_FOUND, `schedule not found: ${input.scheduleId}`);
|
|
107
|
+
}
|
|
108
|
+
const executions = await listExecutionsForSchedule(input.controlRepoRoot, input.scheduleId);
|
|
109
|
+
const lastExecution = [...executions]
|
|
110
|
+
.reverse()
|
|
111
|
+
.find((item) => item.status === "failed" || item.status === "succeeded");
|
|
112
|
+
const admission = await readAdmission(input.controlRepoRoot, input.scheduleId);
|
|
113
|
+
const assessment = await assessPreparedAttemptRecovery({
|
|
114
|
+
controlRepoRoot: input.controlRepoRoot,
|
|
115
|
+
schedule,
|
|
116
|
+
admission,
|
|
117
|
+
lastExecution,
|
|
118
|
+
attemptCount: executions.length,
|
|
119
|
+
...(input.failureCategory
|
|
120
|
+
? { failureCategory: input.failureCategory }
|
|
121
|
+
: {}),
|
|
122
|
+
...(input.terminationUnconfirmed !== undefined
|
|
123
|
+
? { terminationUnconfirmed: input.terminationUnconfirmed }
|
|
124
|
+
: {}),
|
|
125
|
+
...(input.attempt ? { attempt: input.attempt } : {}),
|
|
126
|
+
...(input.workspaceRoot ? { workspaceRoot: input.workspaceRoot } : {}),
|
|
127
|
+
...(input.revalidateWorkspace
|
|
128
|
+
? { revalidateWorkspace: input.revalidateWorkspace }
|
|
129
|
+
: {}),
|
|
130
|
+
...(input.inspectWorkspaceChanges
|
|
131
|
+
? { inspectWorkspaceChanges: input.inspectWorkspaceChanges }
|
|
132
|
+
: {}),
|
|
133
|
+
});
|
|
134
|
+
const decision = decideAutoRetry({
|
|
135
|
+
schedule,
|
|
136
|
+
attemptCount: executions.length,
|
|
137
|
+
...(input.failureCategory
|
|
138
|
+
? { failureCategory: input.failureCategory }
|
|
139
|
+
: { failureCategory: assessment.category }),
|
|
140
|
+
...(input.terminationUnconfirmed !== undefined
|
|
141
|
+
? { terminationUnconfirmed: input.terminationUnconfirmed }
|
|
142
|
+
: { terminationUnconfirmed: assessment.terminationUnconfirmed }),
|
|
143
|
+
recoveryMode: assessment.mode,
|
|
144
|
+
});
|
|
145
|
+
if (!decision.ok) {
|
|
146
|
+
return { ...decision, assessment, schedule };
|
|
147
|
+
}
|
|
148
|
+
if (decision.action === "verify-only") {
|
|
149
|
+
return {
|
|
150
|
+
...decision,
|
|
151
|
+
fromNodeId: assessment.fromNodeId,
|
|
152
|
+
parentDagRunId: assessment.parentDagRunId,
|
|
153
|
+
assessment,
|
|
154
|
+
schedule,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
// full-prepared-reattempt → re-queue waiting
|
|
158
|
+
const now = input.now ?? new Date();
|
|
159
|
+
await requeueTaskPoolReservation({
|
|
160
|
+
controlRepoRoot: input.controlRepoRoot,
|
|
161
|
+
featureId: schedule.featureId,
|
|
162
|
+
taskId: schedule.taskId,
|
|
163
|
+
nightScheduleId: schedule.id,
|
|
164
|
+
now,
|
|
165
|
+
});
|
|
166
|
+
const transitioned = await transitionSchedule({
|
|
167
|
+
controlRepoRoot: input.controlRepoRoot,
|
|
168
|
+
scheduleId: input.scheduleId,
|
|
169
|
+
toStatus: "waiting",
|
|
170
|
+
event: "auto_retry_queued",
|
|
171
|
+
reason: `auto retry ${decision.attempt}/${decision.maxAutoAttempts} for ${decision.category} (${decision.mode})`,
|
|
172
|
+
reasonCode: decision.category === "EnvFailure"
|
|
173
|
+
? "env-failure-auto-retry"
|
|
174
|
+
: "prepared-attempt-auto-retry",
|
|
175
|
+
patch: {
|
|
176
|
+
waitingReason: `auto-retry:${decision.category}:${decision.mode}`,
|
|
177
|
+
nextAttemptAt: now.toISOString(),
|
|
178
|
+
currentExecutionId: null,
|
|
179
|
+
reservation: schedule.reservation ?? {
|
|
180
|
+
taskPoolStatus: "Queued",
|
|
181
|
+
reservedAt: now.toISOString(),
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
now,
|
|
185
|
+
});
|
|
186
|
+
return {
|
|
187
|
+
...decision,
|
|
188
|
+
assessment,
|
|
189
|
+
schedule: transitioned.schedule,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Re-queue a failed schedule for another tick attempt when policy allows.
|
|
194
|
+
* Keeps Queued reservation / admission; never re-generates DAG.
|
|
195
|
+
* Uses full recovery adapter when controlRepoRoot is available.
|
|
196
|
+
*/
|
|
197
|
+
export async function maybeQueueAutoRetry(input) {
|
|
198
|
+
return assessAndDecideAutoRetry(input);
|
|
199
|
+
}
|