@tea-agent/loop-agent 0.28.11 → 0.28.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -1
- package/README.md +3 -1
- package/dist/application/task-lifecycle/advance.js +21 -4
- package/dist/application/task-lifecycle/plan-transitions.js +17 -5
- package/dist/commands/import-prd.js +1 -1
- package/dist/commands/init.js +2 -2
- package/dist/shared/operator/capabilities.js +21 -0
- package/dist/worker/console/operator-actions.js +11 -0
- package/dist/worker/scheduler/cli.js +110 -5
- package/dist/worker/scheduler/clock-install/darwin-launchd.js +238 -0
- package/dist/worker/scheduler/clock-install/linux-systemd-user.js +224 -0
- package/dist/worker/scheduler/clock-install/types.js +1 -0
- package/dist/worker/scheduler/clock-install/win32-schtasks.js +172 -0
- package/dist/worker/scheduler/clock-install.js +284 -0
- package/dist/worker/scheduler/clock.js +420 -0
- package/dist/worker/scheduler/doctor.js +86 -1
- package/dist/worker/scheduler/index.js +2 -0
- package/dist/worker/scheduler/morning-window.js +31 -1
- package/dist/worker/scheduler/paths.js +8 -0
- package/dist/workflows/dag/frontend-implementation-contract.js +189 -6
- package/dist/workflows/dag/init-hybrid.js +26 -9
- package/dist/workflows/dag/output-protocol.js +60 -7
- package/docs/templates/init-managed-agents.md +3 -1
- package/package.json +1 -1
- package/skills/loop-agent/SKILL.md +3 -1
- package/skills/loop-agent/references/command-reference.md +9 -4
- package/skills/loop-agent/references/harness-policy.md +3 -1
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
|
|
4
|
+
import { countActiveLeases, readExecutionLease } from "./lease.js";
|
|
5
|
+
import { getClockInstallReceiptPath, getClockReceiptPath, getLeasesDir, } from "./paths.js";
|
|
6
|
+
import { ensureSchedulerDirs } from "./store.js";
|
|
7
|
+
import { readdir } from "node:fs/promises";
|
|
8
|
+
export const CLOCK_RECEIPT_SCHEMA_VERSION = 1;
|
|
9
|
+
export const CLOCK_INSTALL_SCHEMA_VERSION = 1;
|
|
10
|
+
export const DEFAULT_CLOCK_INTERVAL_SEC = 60;
|
|
11
|
+
export function computeStaleAfterSec(expectedIntervalSec) {
|
|
12
|
+
const interval = Number.isFinite(expectedIntervalSec) && expectedIntervalSec > 0
|
|
13
|
+
? Math.floor(expectedIntervalSec)
|
|
14
|
+
: DEFAULT_CLOCK_INTERVAL_SEC;
|
|
15
|
+
return Math.max(180, 3 * interval);
|
|
16
|
+
}
|
|
17
|
+
export function createRunningClockReceipt(input) {
|
|
18
|
+
const now = input.now ?? new Date();
|
|
19
|
+
return {
|
|
20
|
+
schemaVersion: CLOCK_RECEIPT_SCHEMA_VERSION,
|
|
21
|
+
expectedIntervalSec: input.expectedIntervalSec ?? DEFAULT_CLOCK_INTERVAL_SEC,
|
|
22
|
+
lastAttemptAt: now.toISOString(),
|
|
23
|
+
lastCompletedAt: null,
|
|
24
|
+
lastOutcome: "running",
|
|
25
|
+
lastError: null,
|
|
26
|
+
lastSource: input.source ?? "manual",
|
|
27
|
+
lastDurationMs: null,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export function completeClockReceipt(running, input) {
|
|
31
|
+
const now = input.now ?? new Date();
|
|
32
|
+
const started = Date.parse(running.lastAttemptAt);
|
|
33
|
+
const durationMs = Number.isFinite(started)
|
|
34
|
+
? Math.max(0, now.getTime() - started)
|
|
35
|
+
: null;
|
|
36
|
+
if (input.ok) {
|
|
37
|
+
return {
|
|
38
|
+
...running,
|
|
39
|
+
lastCompletedAt: now.toISOString(),
|
|
40
|
+
lastOutcome: "ok",
|
|
41
|
+
lastError: null,
|
|
42
|
+
lastDurationMs: durationMs,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
...running,
|
|
47
|
+
lastCompletedAt: now.toISOString(),
|
|
48
|
+
lastOutcome: "error",
|
|
49
|
+
lastError: input.error ?? {
|
|
50
|
+
code: "clock-tick-error",
|
|
51
|
+
message: "scheduler tick failed",
|
|
52
|
+
},
|
|
53
|
+
lastDurationMs: durationMs,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export async function readClockReceipt(controlRepoRoot) {
|
|
57
|
+
try {
|
|
58
|
+
const raw = await readFile(getClockReceiptPath(controlRepoRoot), "utf-8");
|
|
59
|
+
const parsed = JSON.parse(raw);
|
|
60
|
+
if (parsed?.schemaVersion !== CLOCK_RECEIPT_SCHEMA_VERSION)
|
|
61
|
+
return null;
|
|
62
|
+
return parsed;
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error &&
|
|
66
|
+
typeof error === "object" &&
|
|
67
|
+
"code" in error &&
|
|
68
|
+
error.code === "ENOENT") {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export async function writeClockReceipt(controlRepoRoot, receipt) {
|
|
75
|
+
await ensureSchedulerDirs(controlRepoRoot);
|
|
76
|
+
await writeJsonAtomic(getClockReceiptPath(controlRepoRoot), receipt, {
|
|
77
|
+
repoRoot: controlRepoRoot,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
export async function readClockInstallReceipt(controlRepoRoot) {
|
|
81
|
+
try {
|
|
82
|
+
const raw = await readFile(getClockInstallReceiptPath(controlRepoRoot), "utf-8");
|
|
83
|
+
const parsed = JSON.parse(raw);
|
|
84
|
+
if (parsed?.schemaVersion !== CLOCK_INSTALL_SCHEMA_VERSION)
|
|
85
|
+
return null;
|
|
86
|
+
return parsed;
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (error &&
|
|
90
|
+
typeof error === "object" &&
|
|
91
|
+
"code" in error &&
|
|
92
|
+
error.code === "ENOENT") {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export async function writeClockInstallReceipt(controlRepoRoot, receipt) {
|
|
99
|
+
await ensureSchedulerDirs(controlRepoRoot);
|
|
100
|
+
await writeJsonAtomic(getClockInstallReceiptPath(controlRepoRoot), receipt, {
|
|
101
|
+
repoRoot: controlRepoRoot,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
export function hashManagedConfig(parts) {
|
|
105
|
+
const payload = [
|
|
106
|
+
parts.provider,
|
|
107
|
+
parts.unitName,
|
|
108
|
+
parts.controlRepoRoot,
|
|
109
|
+
String(parts.intervalSec),
|
|
110
|
+
parts.nodeBin,
|
|
111
|
+
parts.agentWorkerEntry,
|
|
112
|
+
parts.loopAgentBin,
|
|
113
|
+
].join("\n");
|
|
114
|
+
const digest = createHash("sha256").update(payload).digest("hex");
|
|
115
|
+
return `sha256:${digest}`;
|
|
116
|
+
}
|
|
117
|
+
export function deriveClockUnitName(controlRepoRoot) {
|
|
118
|
+
const canonical = canonicalizeControlRoot(controlRepoRoot);
|
|
119
|
+
const digest = createHash("sha256")
|
|
120
|
+
.update(canonical)
|
|
121
|
+
.digest("hex")
|
|
122
|
+
.slice(0, 8);
|
|
123
|
+
return `com.tea-agent.loop-agent.night.${digest}`;
|
|
124
|
+
}
|
|
125
|
+
export function canonicalizeControlRoot(controlRepoRoot) {
|
|
126
|
+
// Keep stable across platforms; realpath is applied by install resolvers.
|
|
127
|
+
return controlRepoRoot.replace(/\\/g, "/").replace(/\/+$/, "") || "/";
|
|
128
|
+
}
|
|
129
|
+
export function classifyClockHealth(input) {
|
|
130
|
+
const now = input.now ?? new Date();
|
|
131
|
+
const receipt = input.receipt ?? null;
|
|
132
|
+
const install = input.install ?? null;
|
|
133
|
+
const expectedIntervalSec = receipt?.expectedIntervalSec ??
|
|
134
|
+
install?.intervalSec ??
|
|
135
|
+
DEFAULT_CLOCK_INTERVAL_SEC;
|
|
136
|
+
const staleAfterSec = computeStaleAfterSec(expectedIntervalSec);
|
|
137
|
+
const activeLeaseCount = input.activeLeaseCount ?? 0;
|
|
138
|
+
const maxConcurrency = input.defaultMaxConcurrency ?? 1;
|
|
139
|
+
const providerSupported = input.providerSupported !== false;
|
|
140
|
+
const base = {
|
|
141
|
+
schemaVersion: 1,
|
|
142
|
+
expectedIntervalSec,
|
|
143
|
+
staleAfterSec,
|
|
144
|
+
provider: install?.provider ?? null,
|
|
145
|
+
unitName: install?.unitName ?? null,
|
|
146
|
+
unitLoaded: input.unitLoaded ?? null,
|
|
147
|
+
install,
|
|
148
|
+
receipt,
|
|
149
|
+
lastSuccessfulTickAt: receipt?.lastOutcome === "ok" ? receipt.lastCompletedAt : null,
|
|
150
|
+
activeLeaseCount,
|
|
151
|
+
};
|
|
152
|
+
if (!providerSupported && !install) {
|
|
153
|
+
return {
|
|
154
|
+
...base,
|
|
155
|
+
status: "unsupported",
|
|
156
|
+
message: "No supported user-level OS timer provider on this host (need launchd, systemd --user, or schtasks).",
|
|
157
|
+
nextAction: "Use agent-worker scheduler tick --repo . manually, or move to a supported platform.",
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
if (!install && (input.unitPresent === false || input.unitPresent == null)) {
|
|
161
|
+
// No install receipt: still allow "observed" health from manual ticks.
|
|
162
|
+
if (receipt) {
|
|
163
|
+
const freshness = classifyReceiptFreshness(receipt, now, staleAfterSec);
|
|
164
|
+
if (freshness === "running") {
|
|
165
|
+
if (activeLeaseCount >= maxConcurrency) {
|
|
166
|
+
return {
|
|
167
|
+
...base,
|
|
168
|
+
status: "busy",
|
|
169
|
+
message: "Clock tick is running and default concurrency capacity is in use.",
|
|
170
|
+
nextAction: null,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
...base,
|
|
175
|
+
status: "healthy",
|
|
176
|
+
message: "Clock tick is currently running (manual or unmanaged).",
|
|
177
|
+
nextAction: "Optional: agent-worker scheduler clock install --repo . for automatic ticks.",
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
if (freshness === "fresh-ok") {
|
|
181
|
+
return {
|
|
182
|
+
...base,
|
|
183
|
+
status: "healthy",
|
|
184
|
+
message: "Recent successful tick observed, but no managed OS timer is installed.",
|
|
185
|
+
nextAction: "Install automatic clock: agent-worker scheduler clock install --repo .",
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
if (freshness === "fresh-error") {
|
|
189
|
+
return {
|
|
190
|
+
...base,
|
|
191
|
+
status: "degraded",
|
|
192
|
+
message: `Recent tick failed: ${receipt.lastError?.message ?? "unknown error"}`,
|
|
193
|
+
nextAction: "Inspect last tick error; re-run scheduler tick.",
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
...base,
|
|
198
|
+
status: "stale",
|
|
199
|
+
message: "Tick receipt is stale and no managed OS timer is installed.",
|
|
200
|
+
nextAction: "agent-worker scheduler clock install --repo .",
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
...base,
|
|
205
|
+
status: "missing",
|
|
206
|
+
message: "No managed clock timer installed and no tick receipt yet.",
|
|
207
|
+
nextAction: "agent-worker scheduler clock install --repo .",
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
if (input.configMatches === false ||
|
|
211
|
+
input.pathsMatch === false ||
|
|
212
|
+
(install && input.unitPresent === false)) {
|
|
213
|
+
return {
|
|
214
|
+
...base,
|
|
215
|
+
status: "drifted",
|
|
216
|
+
message: input.unitPresent === false
|
|
217
|
+
? "Install receipt exists but managed OS unit is missing."
|
|
218
|
+
: "Managed clock unit or package entry has drifted from install receipt.",
|
|
219
|
+
nextAction: "agent-worker scheduler clock install --repo .",
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
if (!receipt) {
|
|
223
|
+
return {
|
|
224
|
+
...base,
|
|
225
|
+
status: "stale",
|
|
226
|
+
message: "Managed clock is installed but no tick receipt has been written yet.",
|
|
227
|
+
nextAction: "Wait for the next timer fire, or run scheduler tick once.",
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
const freshness = classifyReceiptFreshness(receipt, now, staleAfterSec);
|
|
231
|
+
if (freshness === "running") {
|
|
232
|
+
if (activeLeaseCount >= maxConcurrency) {
|
|
233
|
+
return {
|
|
234
|
+
...base,
|
|
235
|
+
status: "busy",
|
|
236
|
+
message: "Clock tick is running and default concurrency capacity is in use.",
|
|
237
|
+
nextAction: null,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
...base,
|
|
242
|
+
status: "healthy",
|
|
243
|
+
message: "Clock tick is currently running.",
|
|
244
|
+
nextAction: null,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
if (freshness === "fresh-error") {
|
|
248
|
+
return {
|
|
249
|
+
...base,
|
|
250
|
+
status: "degraded",
|
|
251
|
+
message: `Last tick failed: ${receipt.lastError?.message ?? "unknown error"}`,
|
|
252
|
+
nextAction: "Inspect scheduler doctor / last tick error, then re-run tick.",
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
if (freshness === "fresh-ok") {
|
|
256
|
+
return {
|
|
257
|
+
...base,
|
|
258
|
+
status: "healthy",
|
|
259
|
+
message: "Managed clock is installed and recent ticks succeeded.",
|
|
260
|
+
nextAction: null,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
...base,
|
|
265
|
+
status: "stale",
|
|
266
|
+
message: `No fresh tick within ${staleAfterSec}s (expected every ${expectedIntervalSec}s).`,
|
|
267
|
+
nextAction: "agent-worker scheduler clock status --repo . → reinstall if needed",
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
function classifyReceiptFreshness(receipt, now, staleAfterSec) {
|
|
271
|
+
const attemptMs = Date.parse(receipt.lastAttemptAt);
|
|
272
|
+
const completedMs = receipt.lastCompletedAt
|
|
273
|
+
? Date.parse(receipt.lastCompletedAt)
|
|
274
|
+
: Number.NaN;
|
|
275
|
+
const freshest = Number.isFinite(completedMs)
|
|
276
|
+
? Math.max(attemptMs, completedMs)
|
|
277
|
+
: attemptMs;
|
|
278
|
+
const ageSec = Number.isFinite(freshest)
|
|
279
|
+
? (now.getTime() - freshest) / 1000
|
|
280
|
+
: Number.POSITIVE_INFINITY;
|
|
281
|
+
if (receipt.lastOutcome === "running" && ageSec <= staleAfterSec) {
|
|
282
|
+
return "running";
|
|
283
|
+
}
|
|
284
|
+
if (ageSec > staleAfterSec)
|
|
285
|
+
return "stale";
|
|
286
|
+
if (receipt.lastOutcome === "error")
|
|
287
|
+
return "fresh-error";
|
|
288
|
+
if (receipt.lastOutcome === "ok")
|
|
289
|
+
return "fresh-ok";
|
|
290
|
+
return "stale";
|
|
291
|
+
}
|
|
292
|
+
export async function countActiveExecutionLeases(controlRepoRoot, now = new Date()) {
|
|
293
|
+
const leases = [];
|
|
294
|
+
try {
|
|
295
|
+
const names = await readdir(getLeasesDir(controlRepoRoot));
|
|
296
|
+
for (const name of names) {
|
|
297
|
+
if (!name.endsWith(".json"))
|
|
298
|
+
continue;
|
|
299
|
+
const scheduleId = name.slice(0, -5);
|
|
300
|
+
const lease = await readExecutionLease(controlRepoRoot, scheduleId);
|
|
301
|
+
if (lease)
|
|
302
|
+
leases.push(lease);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
return 0;
|
|
307
|
+
}
|
|
308
|
+
return countActiveLeases(leases, now);
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Wrap any schedulerTick implementation with Clock receipt bookkeeping.
|
|
312
|
+
* Business semantics stay in schedulerTick; this only records evidence.
|
|
313
|
+
*/
|
|
314
|
+
export async function runRecordedSchedulerTick(input) {
|
|
315
|
+
const now = input.now ?? new Date();
|
|
316
|
+
const running = createRunningClockReceipt({
|
|
317
|
+
now,
|
|
318
|
+
expectedIntervalSec: input.expectedIntervalSec,
|
|
319
|
+
source: input.source ?? "manual",
|
|
320
|
+
});
|
|
321
|
+
// Prefer install interval when present.
|
|
322
|
+
if (!input.expectedIntervalSec) {
|
|
323
|
+
const install = await readClockInstallReceipt(input.controlRepoRoot);
|
|
324
|
+
if (install?.intervalSec) {
|
|
325
|
+
running.expectedIntervalSec = install.intervalSec;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
await writeClockReceipt(input.controlRepoRoot, running);
|
|
329
|
+
try {
|
|
330
|
+
const result = await input.tick();
|
|
331
|
+
const completed = completeClockReceipt(running, {
|
|
332
|
+
now: new Date(),
|
|
333
|
+
ok: true,
|
|
334
|
+
});
|
|
335
|
+
await writeClockReceipt(input.controlRepoRoot, completed);
|
|
336
|
+
return result;
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
const code = error &&
|
|
340
|
+
typeof error === "object" &&
|
|
341
|
+
"code" in error &&
|
|
342
|
+
typeof error.code === "string"
|
|
343
|
+
? error.code
|
|
344
|
+
: "clock-tick-error";
|
|
345
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
346
|
+
const completed = completeClockReceipt(running, {
|
|
347
|
+
now: new Date(),
|
|
348
|
+
ok: false,
|
|
349
|
+
error: { code, message },
|
|
350
|
+
});
|
|
351
|
+
await writeClockReceipt(input.controlRepoRoot, completed);
|
|
352
|
+
throw error;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
export function formatClockHealthHuman(health) {
|
|
356
|
+
const lines = [
|
|
357
|
+
`Clock: ${health.status}`,
|
|
358
|
+
health.provider
|
|
359
|
+
? `Provider: ${health.provider}${health.unitLoaded === true ? " (loaded)" : health.unitLoaded === false ? " (not loaded)" : ""}`
|
|
360
|
+
: "Provider: (none)",
|
|
361
|
+
`Interval: ${health.expectedIntervalSec}s`,
|
|
362
|
+
];
|
|
363
|
+
if (health.unitName)
|
|
364
|
+
lines.push(`Unit: ${health.unitName}`);
|
|
365
|
+
if (health.receipt) {
|
|
366
|
+
const r = health.receipt;
|
|
367
|
+
const when = r.lastCompletedAt ?? r.lastAttemptAt ?? "(never)";
|
|
368
|
+
lines.push(`Last tick: ${r.lastOutcome} at ${when}`);
|
|
369
|
+
if (r.lastError) {
|
|
370
|
+
lines.push(`Last error: ${r.lastError.code}: ${r.lastError.message}`);
|
|
371
|
+
}
|
|
372
|
+
if (r.lastDurationMs != null) {
|
|
373
|
+
lines.push(`Last duration: ${r.lastDurationMs}ms`);
|
|
374
|
+
}
|
|
375
|
+
lines.push(`Source: ${r.lastSource}`);
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
lines.push("Last tick: (none)");
|
|
379
|
+
}
|
|
380
|
+
lines.push(health.message);
|
|
381
|
+
if (health.nextAction) {
|
|
382
|
+
lines.push(`Next: ${health.nextAction}`);
|
|
383
|
+
}
|
|
384
|
+
lines.push("");
|
|
385
|
+
return `${lines.join("\n")}\n`;
|
|
386
|
+
}
|
|
387
|
+
export function clockDoctorSeverity(input) {
|
|
388
|
+
const { health, hasFutureScheduled, hasDueUnclaimed } = input;
|
|
389
|
+
if (health.status === "healthy" || health.status === "busy") {
|
|
390
|
+
return health.status === "busy" ? "info" : null;
|
|
391
|
+
}
|
|
392
|
+
if (health.status === "drifted")
|
|
393
|
+
return "error";
|
|
394
|
+
if (hasDueUnclaimed) {
|
|
395
|
+
if (health.status === "missing" ||
|
|
396
|
+
health.status === "stale" ||
|
|
397
|
+
health.status === "degraded" ||
|
|
398
|
+
health.status === "unsupported") {
|
|
399
|
+
return "error";
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (hasFutureScheduled) {
|
|
403
|
+
if (health.status === "missing" ||
|
|
404
|
+
health.status === "stale" ||
|
|
405
|
+
health.status === "unsupported") {
|
|
406
|
+
return "warning";
|
|
407
|
+
}
|
|
408
|
+
if (health.status === "degraded")
|
|
409
|
+
return "warning";
|
|
410
|
+
}
|
|
411
|
+
// no activity
|
|
412
|
+
if (health.status === "missing" ||
|
|
413
|
+
health.status === "stale" ||
|
|
414
|
+
health.status === "unsupported") {
|
|
415
|
+
return "info";
|
|
416
|
+
}
|
|
417
|
+
if (health.status === "degraded")
|
|
418
|
+
return "warning";
|
|
419
|
+
return "info";
|
|
420
|
+
}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { readdir, readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { listTaskPoolStates } from "../pool/run-store.js";
|
|
4
|
+
import { clockDoctorSeverity, readClockInstallReceipt, readClockReceipt, classifyClockHealth, countActiveExecutionLeases, } from "./clock.js";
|
|
5
|
+
import { selectClockAdapter } from "./clock-install.js";
|
|
4
6
|
import { getLeasesDir, getMutationLockPath, getSchedulerRoot, } from "./paths.js";
|
|
5
7
|
import { classifyLeaseLiveness, classifyLeaseRecovery } from "./recovery.js";
|
|
6
8
|
import { verifyEvidenceArchive } from "./evidence.js";
|
|
7
9
|
import { readExecutionLease } from "./lease.js";
|
|
8
10
|
import { listPendingJournals, listSchedules, readAdmission, readExecution, readLedgerEvents, } from "./store.js";
|
|
11
|
+
import { evaluateOnceDue } from "./trigger.js";
|
|
9
12
|
import { ACTIVE_SCHEDULE_STATUSES, isTerminalScheduleStatus, } from "./types.js";
|
|
10
13
|
/**
|
|
11
14
|
* Read-only consistency check for Night Scheduler facts.
|
|
@@ -96,7 +99,8 @@ export async function diagnoseScheduler(controlRepoRoot, options) {
|
|
|
96
99
|
}
|
|
97
100
|
}
|
|
98
101
|
}
|
|
99
|
-
if (isTerminalScheduleStatus(schedule.status) &&
|
|
102
|
+
if (isTerminalScheduleStatus(schedule.status) &&
|
|
103
|
+
schedule.currentExecutionId) {
|
|
100
104
|
const evidence = await verifyEvidenceArchive({
|
|
101
105
|
controlRepoRoot,
|
|
102
106
|
executionId: schedule.currentExecutionId,
|
|
@@ -306,6 +310,87 @@ export async function diagnoseScheduler(controlRepoRoot, options) {
|
|
|
306
310
|
message: "scheduler root does not exist yet (no schedules submitted)",
|
|
307
311
|
});
|
|
308
312
|
}
|
|
313
|
+
// Clock health (OS timer + tick receipt)
|
|
314
|
+
try {
|
|
315
|
+
const [clockReceipt, clockInstall, activeLeaseCount] = await Promise.all([
|
|
316
|
+
readClockReceipt(controlRepoRoot),
|
|
317
|
+
readClockInstallReceipt(controlRepoRoot),
|
|
318
|
+
countActiveExecutionLeases(controlRepoRoot, now),
|
|
319
|
+
]);
|
|
320
|
+
const adapter = selectClockAdapter();
|
|
321
|
+
let unitPresent = null;
|
|
322
|
+
let unitLoaded = null;
|
|
323
|
+
let configMatches = null;
|
|
324
|
+
let providerSupported = adapter != null;
|
|
325
|
+
if (adapter) {
|
|
326
|
+
providerSupported = await adapter.isAvailable();
|
|
327
|
+
if (clockInstall) {
|
|
328
|
+
const probe = await adapter.probe({
|
|
329
|
+
receipt: clockInstall,
|
|
330
|
+
unitName: clockInstall.unitName,
|
|
331
|
+
});
|
|
332
|
+
unitPresent = probe.present;
|
|
333
|
+
unitLoaded = probe.loaded;
|
|
334
|
+
configMatches = probe.configMatches;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const health = classifyClockHealth({
|
|
338
|
+
now,
|
|
339
|
+
receipt: clockReceipt,
|
|
340
|
+
install: clockInstall,
|
|
341
|
+
unitPresent,
|
|
342
|
+
unitLoaded,
|
|
343
|
+
configMatches,
|
|
344
|
+
providerSupported,
|
|
345
|
+
activeLeaseCount,
|
|
346
|
+
defaultMaxConcurrency: 1,
|
|
347
|
+
});
|
|
348
|
+
const futureScheduled = schedules.some((schedule) => (schedule.status === "scheduled" || schedule.status === "waiting") &&
|
|
349
|
+
schedule.trigger?.type === "once" &&
|
|
350
|
+
Date.parse(schedule.trigger.executeAtUtc) > now.getTime());
|
|
351
|
+
const dueUnclaimed = schedules.some((schedule) => {
|
|
352
|
+
if (schedule.status !== "scheduled" && schedule.status !== "waiting") {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
const trigger = schedule.trigger;
|
|
356
|
+
if (!trigger || trigger.type !== "once")
|
|
357
|
+
return false;
|
|
358
|
+
const decision = evaluateOnceDue({
|
|
359
|
+
nowUtc: now,
|
|
360
|
+
executeAtUtc: trigger.executeAtUtc,
|
|
361
|
+
...(trigger.latestStartAtUtc
|
|
362
|
+
? { latestStartAtUtc: trigger.latestStartAtUtc }
|
|
363
|
+
: {}),
|
|
364
|
+
timezone: trigger.displayTimezone ??
|
|
365
|
+
schedule.policySnapshot?.timezone ??
|
|
366
|
+
"Asia/Shanghai",
|
|
367
|
+
allowedHours: schedule.policySnapshot?.allowedHours ?? ["00:00-08:00"],
|
|
368
|
+
misfirePolicy: trigger.misfirePolicy ??
|
|
369
|
+
schedule.policySnapshot?.misfirePolicy ??
|
|
370
|
+
"wait-next-window",
|
|
371
|
+
});
|
|
372
|
+
return decision.kind === "eligible";
|
|
373
|
+
});
|
|
374
|
+
const severity = clockDoctorSeverity({
|
|
375
|
+
health,
|
|
376
|
+
hasFutureScheduled: futureScheduled,
|
|
377
|
+
hasDueUnclaimed: dueUnclaimed,
|
|
378
|
+
});
|
|
379
|
+
if (severity) {
|
|
380
|
+
findings.push({
|
|
381
|
+
code: `clock-${health.status}`,
|
|
382
|
+
severity,
|
|
383
|
+
message: `${health.message}${health.nextAction ? ` Next: ${health.nextAction}` : ""}`,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
findings.push({
|
|
389
|
+
code: "clock-unreadable",
|
|
390
|
+
severity: "warning",
|
|
391
|
+
message: `cannot classify clock health: ${error instanceof Error ? error.message : String(error)}`,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
309
394
|
const activeCount = schedules.filter((item) => ACTIVE_SCHEDULE_STATUSES.has(item.status)).length;
|
|
310
395
|
const errors = findings.filter((item) => item.severity === "error");
|
|
311
396
|
return {
|
|
@@ -20,4 +20,6 @@ export * from "./recovery.js";
|
|
|
20
20
|
export * from "./retry.js";
|
|
21
21
|
export * from "./prepared-attempt-recovery.js";
|
|
22
22
|
export * from "./auto-followup.js";
|
|
23
|
+
export * from "./clock.js";
|
|
24
|
+
export * from "./clock-install.js";
|
|
23
25
|
export { registerSchedulerCommands } from "./cli.js";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { classifyClockHealth, readClockInstallReceipt, readClockReceipt, } from "./clock.js";
|
|
1
2
|
import { listSchedules } from "./store.js";
|
|
2
3
|
/**
|
|
3
4
|
* Project Night Scheduler facts for morning report --window night.
|
|
@@ -23,6 +24,17 @@ export async function buildNightMorningWindow(input) {
|
|
|
23
24
|
row.status === "waiting" ||
|
|
24
25
|
row.status === "validated").length,
|
|
25
26
|
};
|
|
27
|
+
const [clockReceipt, clockInstall] = await Promise.all([
|
|
28
|
+
readClockReceipt(input.controlRepoRoot),
|
|
29
|
+
readClockInstallReceipt(input.controlRepoRoot),
|
|
30
|
+
]);
|
|
31
|
+
const clockHealth = classifyClockHealth({
|
|
32
|
+
now,
|
|
33
|
+
receipt: clockReceipt,
|
|
34
|
+
install: clockInstall,
|
|
35
|
+
unitPresent: clockInstall ? true : null,
|
|
36
|
+
providerSupported: true,
|
|
37
|
+
});
|
|
26
38
|
return {
|
|
27
39
|
schemaVersion: 1,
|
|
28
40
|
window: "night",
|
|
@@ -30,6 +42,13 @@ export async function buildNightMorningWindow(input) {
|
|
|
30
42
|
timezone,
|
|
31
43
|
generatedAt: now.toISOString(),
|
|
32
44
|
schedules: rows,
|
|
45
|
+
clock: {
|
|
46
|
+
status: clockHealth.status,
|
|
47
|
+
message: clockHealth.message,
|
|
48
|
+
lastSuccessfulTickAt: clockHealth.lastSuccessfulTickAt,
|
|
49
|
+
expectedIntervalSec: clockHealth.expectedIntervalSec,
|
|
50
|
+
nextAction: clockHealth.nextAction,
|
|
51
|
+
},
|
|
33
52
|
summary,
|
|
34
53
|
};
|
|
35
54
|
}
|
|
@@ -49,6 +68,17 @@ export function renderNightMorningMarkdown(window) {
|
|
|
49
68
|
`- Pending harvest: ${window.summary.pendingHarvest}`,
|
|
50
69
|
`- Still scheduled/waiting: ${window.summary.scheduledOrWaiting}`,
|
|
51
70
|
"",
|
|
71
|
+
"## Clock",
|
|
72
|
+
"",
|
|
73
|
+
window.clock
|
|
74
|
+
? `- Status: ${window.clock.status} (interval ${window.clock.expectedIntervalSec}s)`
|
|
75
|
+
: "- Status: unknown",
|
|
76
|
+
window.clock?.lastSuccessfulTickAt
|
|
77
|
+
? `- Last successful tick: ${window.clock.lastSuccessfulTickAt}`
|
|
78
|
+
: "- Last successful tick: (none)",
|
|
79
|
+
window.clock ? `- Detail: ${window.clock.message}` : "",
|
|
80
|
+
window.clock?.nextAction ? `- Next: ${window.clock.nextAction}` : "",
|
|
81
|
+
"",
|
|
52
82
|
"## Schedules",
|
|
53
83
|
"",
|
|
54
84
|
"| Schedule | Task | Plan | Status | Merge | Card | Next |",
|
|
@@ -123,7 +153,7 @@ export function resolveNextAction(schedule) {
|
|
|
123
153
|
return `agent-worker scheduler discard ${schedule.id} --repo . --reason "..."${schedule.status === "failed" || schedule.status === "human_required" ? " --force" : ""}`;
|
|
124
154
|
}
|
|
125
155
|
if (schedule.status === "scheduled" || schedule.status === "waiting") {
|
|
126
|
-
return "wait for scheduler tick
|
|
156
|
+
return "wait for OS clock timer / scheduler tick; inspect: agent-worker scheduler clock status --repo .";
|
|
127
157
|
}
|
|
128
158
|
if (schedule.status === "validated") {
|
|
129
159
|
return `agent-worker scheduler add ${schedule.id} --approve-gate <token> --repo .`;
|
|
@@ -40,6 +40,14 @@ export function getLedgerPath(controlRepoRoot) {
|
|
|
40
40
|
export function getMutationLockPath(controlRepoRoot) {
|
|
41
41
|
return path.join(getSchedulerRoot(controlRepoRoot), "mutation.lock");
|
|
42
42
|
}
|
|
43
|
+
/** Actual tick receipt (manual + OS timer share this file). */
|
|
44
|
+
export function getClockReceiptPath(controlRepoRoot) {
|
|
45
|
+
return path.join(getSchedulerRoot(controlRepoRoot), "clock.json");
|
|
46
|
+
}
|
|
47
|
+
/** Managed OS timer install receipt (not the same as tick evidence). */
|
|
48
|
+
export function getClockInstallReceiptPath(controlRepoRoot) {
|
|
49
|
+
return path.join(getSchedulerRoot(controlRepoRoot), "clock-install.json");
|
|
50
|
+
}
|
|
43
51
|
export function getSchedulePath(controlRepoRoot, scheduleId) {
|
|
44
52
|
assertSafeSchedulerId(scheduleId, "scheduleId");
|
|
45
53
|
const schedulePath = path.join(getSchedulesDir(controlRepoRoot), `${scheduleId}.json`);
|