@tea-agent/loop-agent 0.28.12-beta.1 → 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.
@@ -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) && schedule.currentExecutionId) {
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 / inspect schedule status";
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`);
@@ -99,7 +99,7 @@ loop-agent task advance <task-id> "任务标题" \
99
99
  --prd <path-to-prd.md> \
100
100
  --allowed-path "<glob>" \
101
101
  --forbidden-path ".harness/**" \
102
- --verify "typecheck:npm run typecheck" \
102
+ --verify "<label>:<command>" \
103
103
  --json
104
104
  # 审查 writeSet gate digest 后:
105
105
  loop-agent task advance <task-id> --approve-gate "write-set-review:<digest>" --json
@@ -108,6 +108,8 @@ loop-agent task status <task-id> --json
108
108
  # 有 plan 时收尾:loop-agent plan complete <plan-id> --summary "..."
109
109
  ```
110
110
 
111
+ `--verify` 命令应取项目 `AGENTS.md` / `__LOOP_AGENT_GOVERNANCE_ROOT__/verification-matrix.md` 登记的验证命令(不要假定 `npm run typecheck` 存在);`--verify` 可选,省略时自动从 package.json scripts 或既有 managed `task.json.verifyCommands` 推导建议。
112
+
111
113
  `source/需求.md` 与 `source/执行约束.md` 仍必需(M8/M9),但默认由 `task advance --prd` 投影生成,而不是主会话手写。`plan create` 不是 `task advance` 的硬依赖。写入前同步 `task.json.allowedPaths` / `task.json.forbiddenPaths` 并审查 writer `writeSet`。高级任意 DagSpec 才用 `dag validate|execute|report`,不进入标准 happy path。
112
114
 
113
115
  凡是影响项目公共契约、执行入口、交付流水线、自动化/治理、数据模型、安全或权限模型、跨模块行为、用户可见工作流的改动,都必须在编辑实现文件前先通过 `task advance` 建立 managed contract / writeSet gate,并审查 gate digest。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tea-agent/loop-agent",
3
- "version": "0.28.12-beta.1",
3
+ "version": "0.28.12",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "loop-agent": "bin/loop-agent.js",
@@ -32,12 +32,14 @@ references:
32
32
  ```bash
33
33
  loop-agent task advance <task-id> "Title" --prd <prd.md> \
34
34
  --allowed-path "<glob>" --forbidden-path ".harness/**" \
35
- --verify "typecheck:npm run typecheck" --json
35
+ --verify "<label>:<command>" --json
36
36
  loop-agent task advance <task-id> \
37
37
  --approve-gate "write-set-review:<digest>" --json
38
38
  loop-agent task status <task-id> --json
39
39
  ```
40
40
 
41
+ `--verify` 命令应取项目 `AGENTS.md` / 治理文档 verification-matrix.md 登记的验证命令(不要假定 `npm run typecheck` 存在);`--verify` 可选,省略时自动从 package.json scripts 或既有 managed `task.json.verifyCommands` 推导建议。
42
+
41
43
  非微小跨会话任务可先 `plan create`。主会话只审 gate/writeSet 与验证证据,不拼低层 prepare/run-task/run-dag/promote/closeout 命令串。
42
44
 
43
45
  ## 主题路由