@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,539 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { LoopAgentClient } from "../loop-agent/loop-agent-client.js";
|
|
3
|
+
import { addSchedule, cancelScheduledReservation, prepareAdmission, } from "./admission.js";
|
|
4
|
+
import { getScheduleStatus, listScheduleSnapshots, submitSchedule, transitionSchedule, } from "./lifecycle.js";
|
|
5
|
+
import { diagnoseScheduler, formatSchedulerDoctorHuman } from "./doctor.js";
|
|
6
|
+
import { readAdmission, readLedgerEvents, recoverAllPendingJournals, } from "./store.js";
|
|
7
|
+
import { discardNightSchedule, harvestNightSchedule } from "./night-harvest.js";
|
|
8
|
+
import { schedulerTick } from "./scheduler-loop.js";
|
|
9
|
+
import { scheduleStatusSchema, SchedulerError, } from "./types.js";
|
|
10
|
+
import { parseOnceLocalToUtc } from "./trigger.js";
|
|
11
|
+
/**
|
|
12
|
+
* Register `agent-worker scheduler` + `admission` commands.
|
|
13
|
+
* Phase 2: prepare + add + cancel reservation. Live tick lands in Phase 3.
|
|
14
|
+
*/
|
|
15
|
+
export function registerSchedulerCommands(program) {
|
|
16
|
+
const admission = program
|
|
17
|
+
.command("admission")
|
|
18
|
+
.description("Night Scheduler admission prepare / inspect (daytime planning)");
|
|
19
|
+
admission
|
|
20
|
+
.command("prepare")
|
|
21
|
+
.description("Create schedule worktree, materialize/validate DAG to writeSet gate, freeze admission")
|
|
22
|
+
.requiredOption("--repo <repo-root>", "Control repo root")
|
|
23
|
+
.requiredOption("--feature-dir <path>", "Feature directory")
|
|
24
|
+
.requiredOption("--task-id <task-id>", "TaskSpec id")
|
|
25
|
+
.requiredOption("--at <local-time>", 'Local execute time "YYYY-MM-DD HH:mm"')
|
|
26
|
+
.option("--tz <timezone>", "IANA timezone for --at", "Asia/Shanghai")
|
|
27
|
+
.option("--task-card <ref>", "Task card / work item ref")
|
|
28
|
+
.option("--loop-agent-bin <path>", "Published loop-agent binary")
|
|
29
|
+
.option("--json", "Emit JSON")
|
|
30
|
+
.action(async (options) => {
|
|
31
|
+
try {
|
|
32
|
+
const controlRepoRoot = path.resolve(options.repo);
|
|
33
|
+
const client = new LoopAgentClient({
|
|
34
|
+
loopAgentBin: options.loopAgentBin ?? "loop-agent",
|
|
35
|
+
artifactRoot: path.join(controlRepoRoot, ".harness", "task-pool", "scheduler", "client-artifacts"),
|
|
36
|
+
resolveIdentity: true,
|
|
37
|
+
});
|
|
38
|
+
const result = await prepareAdmission({
|
|
39
|
+
controlRepoRoot,
|
|
40
|
+
featureDir: options.featureDir,
|
|
41
|
+
taskId: options.taskId,
|
|
42
|
+
at: options.at,
|
|
43
|
+
timezone: options.tz,
|
|
44
|
+
...(options.taskCard ? { taskCard: options.taskCard } : {}),
|
|
45
|
+
client,
|
|
46
|
+
});
|
|
47
|
+
emit(options.json, result, () => formatReviewPacketHuman(result.reviewPacket));
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
handleSchedulerCliError(error, options.json === true);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
admission
|
|
54
|
+
.command("show")
|
|
55
|
+
.description("Show frozen admission for a schedule")
|
|
56
|
+
.argument("<schedule-id>", "Schedule id")
|
|
57
|
+
.requiredOption("--repo <repo-root>", "Control repo root")
|
|
58
|
+
.option("--json", "Emit JSON")
|
|
59
|
+
.action(async (scheduleId, options) => {
|
|
60
|
+
try {
|
|
61
|
+
const admission = await readAdmission(path.resolve(options.repo), scheduleId);
|
|
62
|
+
if (!admission) {
|
|
63
|
+
throw new SchedulerError("scheduler-not-found", `admission not found: ${scheduleId}`);
|
|
64
|
+
}
|
|
65
|
+
emit(options.json, admission, () => `${JSON.stringify(admission, null, 2)}\n`);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
handleSchedulerCliError(error, options.json === true);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
const scheduler = program
|
|
72
|
+
.command("scheduler")
|
|
73
|
+
.description("Night Scheduler facts, lifecycle, admission commit, and doctor");
|
|
74
|
+
scheduler
|
|
75
|
+
.command("add")
|
|
76
|
+
.description("Approve frozen gate receipt and reserve Task Pool Queued (no DAG execute)")
|
|
77
|
+
.argument("<schedule-id>", "Schedule id from admission prepare")
|
|
78
|
+
.requiredOption("--repo <repo-root>", "Control repo root")
|
|
79
|
+
.requiredOption("--approve-gate <token>", "Gate token from admission review packet")
|
|
80
|
+
.option("--json", "Emit JSON")
|
|
81
|
+
.action(async (scheduleId, options) => {
|
|
82
|
+
try {
|
|
83
|
+
const result = await addSchedule({
|
|
84
|
+
controlRepoRoot: path.resolve(options.repo),
|
|
85
|
+
scheduleId,
|
|
86
|
+
approveGate: options.approveGate,
|
|
87
|
+
});
|
|
88
|
+
emit(options.json, result, () => formatScheduleHuman(result.schedule));
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
handleSchedulerCliError(error, options.json === true);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
scheduler
|
|
95
|
+
.command("submit")
|
|
96
|
+
.description("Create a submitted schedule fact (planning intent; no admission yet)")
|
|
97
|
+
.requiredOption("--repo <repo-root>", "Control repo root")
|
|
98
|
+
.requiredOption("--feature-id <feature-id>", "Feature id")
|
|
99
|
+
.requiredOption("--task-id <task-id>", "Task id")
|
|
100
|
+
.option("--feature-dir <path>", "Feature directory relative to repo")
|
|
101
|
+
.option("--task-card <ref>", "Task card / work item ref (e.g. UCP-LOOP#11)")
|
|
102
|
+
.option("--at <local-time>", 'Local execute time "YYYY-MM-DD HH:mm"')
|
|
103
|
+
.option("--tz <timezone>", "IANA timezone for --at", "Asia/Shanghai")
|
|
104
|
+
.option("--json", "Emit JSON")
|
|
105
|
+
.action(async (options) => {
|
|
106
|
+
try {
|
|
107
|
+
const controlRepoRoot = path.resolve(options.repo);
|
|
108
|
+
const trigger = options.at
|
|
109
|
+
? parseOnceLocalToUtc({
|
|
110
|
+
localAt: options.at,
|
|
111
|
+
timezone: options.tz ?? "Asia/Shanghai",
|
|
112
|
+
})
|
|
113
|
+
: undefined;
|
|
114
|
+
const result = await submitSchedule({
|
|
115
|
+
controlRepoRoot,
|
|
116
|
+
featureId: options.featureId,
|
|
117
|
+
taskId: options.taskId,
|
|
118
|
+
...(options.featureDir ? { featureDir: options.featureDir } : {}),
|
|
119
|
+
...(options.taskCard ? { workItemRef: options.taskCard } : {}),
|
|
120
|
+
...(trigger
|
|
121
|
+
? {
|
|
122
|
+
trigger: {
|
|
123
|
+
type: "once",
|
|
124
|
+
executeAtUtc: trigger.executeAtUtc,
|
|
125
|
+
displayTimezone: trigger.displayTimezone,
|
|
126
|
+
...(trigger.latestStartAtUtc
|
|
127
|
+
? { latestStartAtUtc: trigger.latestStartAtUtc }
|
|
128
|
+
: {}),
|
|
129
|
+
misfirePolicy: trigger.misfirePolicy,
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
: {}),
|
|
133
|
+
});
|
|
134
|
+
emit(options.json, result.schedule, () => formatScheduleHuman(result.schedule));
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
handleSchedulerCliError(error, options.json === true);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
scheduler
|
|
141
|
+
.command("list")
|
|
142
|
+
.description("List current schedule snapshots")
|
|
143
|
+
.requiredOption("--repo <repo-root>", "Control repo root")
|
|
144
|
+
.option("--status <status>", "Filter by current status (comma-separated allowed)")
|
|
145
|
+
.option("--json", "Emit JSON")
|
|
146
|
+
.action(async (options) => {
|
|
147
|
+
try {
|
|
148
|
+
const controlRepoRoot = path.resolve(options.repo);
|
|
149
|
+
const statusFilter = parseStatusFilter(options.status);
|
|
150
|
+
const schedules = await listScheduleSnapshots({
|
|
151
|
+
controlRepoRoot,
|
|
152
|
+
...(statusFilter ? { status: statusFilter } : {}),
|
|
153
|
+
});
|
|
154
|
+
if (options.json) {
|
|
155
|
+
process.stdout.write(`${JSON.stringify({ schemaVersion: 1, schedules }, null, 2)}\n`);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
process.stdout.write(formatScheduleListHuman(schedules));
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
handleSchedulerCliError(error, options.json === true);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
scheduler
|
|
165
|
+
.command("status")
|
|
166
|
+
.description("Show one schedule current snapshot")
|
|
167
|
+
.argument("<schedule-id>", "Schedule id")
|
|
168
|
+
.requiredOption("--repo <repo-root>", "Control repo root")
|
|
169
|
+
.option("--json", "Emit JSON")
|
|
170
|
+
.action(async (scheduleId, options) => {
|
|
171
|
+
try {
|
|
172
|
+
const schedule = await getScheduleStatus({
|
|
173
|
+
controlRepoRoot: path.resolve(options.repo),
|
|
174
|
+
scheduleId,
|
|
175
|
+
});
|
|
176
|
+
emit(options.json, schedule, () => formatScheduleHuman(schedule));
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
handleSchedulerCliError(error, options.json === true);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
scheduler
|
|
183
|
+
.command("ledger")
|
|
184
|
+
.description("Query append-only schedule lifecycle ledger")
|
|
185
|
+
.requiredOption("--repo <repo-root>", "Control repo root")
|
|
186
|
+
.option("--schedule-id <id>", "Filter by schedule id")
|
|
187
|
+
.option("--to-status <status>", "Filter by toStatus")
|
|
188
|
+
.option("--json", "Emit JSON")
|
|
189
|
+
.action(async (options) => {
|
|
190
|
+
try {
|
|
191
|
+
const events = await readLedgerEvents(path.resolve(options.repo), {
|
|
192
|
+
...(options.scheduleId ? { scheduleId: options.scheduleId } : {}),
|
|
193
|
+
...(options.toStatus ? { toStatus: options.toStatus } : {}),
|
|
194
|
+
});
|
|
195
|
+
if (options.json) {
|
|
196
|
+
process.stdout.write(`${JSON.stringify({ schemaVersion: 1, events }, null, 2)}\n`);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
process.stdout.write(formatLedgerHuman(events));
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
handleSchedulerCliError(error, options.json === true);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
scheduler
|
|
206
|
+
.command("cancel")
|
|
207
|
+
.description("Cancel a non-running schedule and release Queued reservation when bound")
|
|
208
|
+
.argument("<schedule-id>", "Schedule id")
|
|
209
|
+
.requiredOption("--repo <repo-root>", "Control repo root")
|
|
210
|
+
.requiredOption("--reason <reason>", "Cancel reason")
|
|
211
|
+
.option("--json", "Emit JSON")
|
|
212
|
+
.action(async (scheduleId, options) => {
|
|
213
|
+
try {
|
|
214
|
+
const schedule = await cancelScheduledReservation({
|
|
215
|
+
controlRepoRoot: path.resolve(options.repo),
|
|
216
|
+
scheduleId,
|
|
217
|
+
reason: options.reason,
|
|
218
|
+
});
|
|
219
|
+
emit(options.json, schedule, () => formatScheduleHuman(schedule));
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
handleSchedulerCliError(error, options.json === true);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
scheduler
|
|
226
|
+
.command("transition")
|
|
227
|
+
.description("Internal/test helper: apply a legal lifecycle transition (no dispatch)")
|
|
228
|
+
.argument("<schedule-id>", "Schedule id")
|
|
229
|
+
.requiredOption("--repo <repo-root>", "Control repo root")
|
|
230
|
+
.requiredOption("--to <status>", "Target status")
|
|
231
|
+
.option("--reason <reason>", "Reason text")
|
|
232
|
+
.option("--json", "Emit JSON")
|
|
233
|
+
.action(async (scheduleId, options) => {
|
|
234
|
+
try {
|
|
235
|
+
const toStatus = scheduleStatusSchema.parse(options.to);
|
|
236
|
+
const result = await transitionSchedule({
|
|
237
|
+
controlRepoRoot: path.resolve(options.repo),
|
|
238
|
+
scheduleId,
|
|
239
|
+
toStatus,
|
|
240
|
+
...(options.reason ? { reason: options.reason } : {}),
|
|
241
|
+
});
|
|
242
|
+
emit(options.json, result.schedule, () => formatScheduleHuman(result.schedule));
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
handleSchedulerCliError(error, options.json === true);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
scheduler
|
|
249
|
+
.command("tick")
|
|
250
|
+
.description("Claim due once schedules and dispatch prepared execution (OS cron entry)")
|
|
251
|
+
.requiredOption("--repo <repo-root>", "Control repo root")
|
|
252
|
+
.option("--loop-agent-bin <path>", "Published loop-agent binary")
|
|
253
|
+
.option("--max-claims <n>", "Max schedules to claim this tick", "1")
|
|
254
|
+
.option("--json", "Emit JSON")
|
|
255
|
+
.action(async (options) => {
|
|
256
|
+
try {
|
|
257
|
+
const controlRepoRoot = path.resolve(options.repo);
|
|
258
|
+
const client = new LoopAgentClient({
|
|
259
|
+
loopAgentBin: options.loopAgentBin ?? "loop-agent",
|
|
260
|
+
artifactRoot: path.join(controlRepoRoot, ".harness", "task-pool", "scheduler", "client-artifacts"),
|
|
261
|
+
resolveIdentity: true,
|
|
262
|
+
});
|
|
263
|
+
const maxClaims = Number(options.maxClaims ?? "1");
|
|
264
|
+
const result = await schedulerTick({
|
|
265
|
+
controlRepoRoot,
|
|
266
|
+
client,
|
|
267
|
+
maxClaims: Number.isFinite(maxClaims) ? maxClaims : 1,
|
|
268
|
+
});
|
|
269
|
+
if (options.json) {
|
|
270
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
process.stdout.write(formatTickHuman(result));
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
handleSchedulerCliError(error, options.json === true);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
scheduler
|
|
280
|
+
.command("harvest")
|
|
281
|
+
.description("Exact-base fast-forward night branch into base (succeeded + pending-harvest only)")
|
|
282
|
+
.argument("<schedule-id>", "Schedule id")
|
|
283
|
+
.requiredOption("--repo <repo-root>", "Control repo root")
|
|
284
|
+
.option("--keep-worktree", "Keep night worktree/branch after harvest")
|
|
285
|
+
.option("--json", "Emit JSON")
|
|
286
|
+
.action(async (scheduleId, options) => {
|
|
287
|
+
try {
|
|
288
|
+
const result = await harvestNightSchedule({
|
|
289
|
+
controlRepoRoot: path.resolve(options.repo),
|
|
290
|
+
scheduleId,
|
|
291
|
+
keepWorktree: options.keepWorktree === true,
|
|
292
|
+
});
|
|
293
|
+
if (!result.ok) {
|
|
294
|
+
if (options.json) {
|
|
295
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
process.stderr.write(`harvest blocked (${result.code}): ${result.message}\nnext: ${result.nextAction}\n`);
|
|
299
|
+
}
|
|
300
|
+
process.exitCode = 1;
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
emit(options.json, result, () => [
|
|
304
|
+
`HARVESTED ${result.scheduleId}`,
|
|
305
|
+
` base: ${result.baseBranch}@${result.mergedCommit.slice(0, 12)}`,
|
|
306
|
+
` night: ${result.nightBranch}`,
|
|
307
|
+
` ff: yes`,
|
|
308
|
+
` worktree: ${result.worktreeRemoved ? "removed" : "kept"}`,
|
|
309
|
+
` branch: ${result.branchDeleted ? "deleted" : "kept"}`,
|
|
310
|
+
"",
|
|
311
|
+
].join("\n"));
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
handleSchedulerCliError(error, options.json === true);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
scheduler
|
|
318
|
+
.command("discard")
|
|
319
|
+
.description("Discard night worktree/branch after evidence retained (failed needs --force)")
|
|
320
|
+
.argument("<schedule-id>", "Schedule id")
|
|
321
|
+
.requiredOption("--repo <repo-root>", "Control repo root")
|
|
322
|
+
.requiredOption("--reason <reason>", "Why this schedule is discarded")
|
|
323
|
+
.option("--force", "Required for failed/human_required schedules")
|
|
324
|
+
.option("--json", "Emit JSON")
|
|
325
|
+
.action(async (scheduleId, options) => {
|
|
326
|
+
try {
|
|
327
|
+
const result = await discardNightSchedule({
|
|
328
|
+
controlRepoRoot: path.resolve(options.repo),
|
|
329
|
+
scheduleId,
|
|
330
|
+
reason: options.reason,
|
|
331
|
+
force: options.force === true,
|
|
332
|
+
});
|
|
333
|
+
if (!result.ok) {
|
|
334
|
+
if (options.json) {
|
|
335
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
process.stderr.write(`discard blocked (${result.code}): ${result.message}\nnext: ${result.nextAction}\n`);
|
|
339
|
+
}
|
|
340
|
+
process.exitCode = 1;
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
emit(options.json, result, () => [
|
|
344
|
+
`DISCARDED ${result.scheduleId}`,
|
|
345
|
+
` reason: ${result.reason}`,
|
|
346
|
+
` worktree: ${result.worktreeRemoved ? "removed" : "kept/missing"}`,
|
|
347
|
+
` branch: ${result.branchDeleted ? "deleted" : "kept/missing"}`,
|
|
348
|
+
"",
|
|
349
|
+
].join("\n"));
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
handleSchedulerCliError(error, options.json === true);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
scheduler
|
|
356
|
+
.command("doctor")
|
|
357
|
+
.description("Read-only Night Scheduler consistency diagnosis")
|
|
358
|
+
.requiredOption("--repo <repo-root>", "Control repo root")
|
|
359
|
+
.option("--json", "Emit JSON")
|
|
360
|
+
.option("--recover-journals", "Replay pending transition journals before diagnosis")
|
|
361
|
+
.action(async (options) => {
|
|
362
|
+
try {
|
|
363
|
+
const controlRepoRoot = path.resolve(options.repo);
|
|
364
|
+
if (options.recoverJournals) {
|
|
365
|
+
await recoverAllPendingJournals(controlRepoRoot);
|
|
366
|
+
}
|
|
367
|
+
const report = await diagnoseScheduler(controlRepoRoot);
|
|
368
|
+
if (options.json) {
|
|
369
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
process.stdout.write(formatSchedulerDoctorHuman(report));
|
|
373
|
+
if (!report.ok)
|
|
374
|
+
process.exitCode = 1;
|
|
375
|
+
}
|
|
376
|
+
catch (error) {
|
|
377
|
+
handleSchedulerCliError(error, options.json === true);
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
function parseStatusFilter(raw) {
|
|
382
|
+
if (!raw)
|
|
383
|
+
return undefined;
|
|
384
|
+
const parts = raw
|
|
385
|
+
.split(",")
|
|
386
|
+
.map((part) => part.trim())
|
|
387
|
+
.filter(Boolean);
|
|
388
|
+
const parsed = parts.map((part) => scheduleStatusSchema.parse(part));
|
|
389
|
+
if (parsed.length === 0)
|
|
390
|
+
return undefined;
|
|
391
|
+
if (parsed.length === 1)
|
|
392
|
+
return parsed[0];
|
|
393
|
+
return parsed;
|
|
394
|
+
}
|
|
395
|
+
function emit(json, value, human) {
|
|
396
|
+
if (json) {
|
|
397
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
process.stdout.write(human());
|
|
401
|
+
}
|
|
402
|
+
function formatTickHuman(result) {
|
|
403
|
+
const lines = [
|
|
404
|
+
`Night Scheduler tick @ ${result.at}`,
|
|
405
|
+
` claimed: ${result.claimed.length ? result.claimed.join(", ") : "(none)"}`,
|
|
406
|
+
` dispatched: ${result.dispatched.length}`,
|
|
407
|
+
];
|
|
408
|
+
for (const item of result.dispatched) {
|
|
409
|
+
lines.push(` - ${item.schedule.id} → ${item.schedule.status}`);
|
|
410
|
+
}
|
|
411
|
+
if (result.recovery?.length) {
|
|
412
|
+
lines.push(" recovery:");
|
|
413
|
+
for (const item of result.recovery) {
|
|
414
|
+
lines.push(` - ${item.scheduleId}: ${item.action} (${item.detail})`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (result.waiting.length) {
|
|
418
|
+
lines.push(" waiting:");
|
|
419
|
+
for (const item of result.waiting) {
|
|
420
|
+
lines.push(` - ${item.scheduleId}: ${item.reason}`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (result.skipped.length) {
|
|
424
|
+
lines.push(" skipped:");
|
|
425
|
+
for (const item of result.skipped) {
|
|
426
|
+
lines.push(` - ${item.scheduleId}: ${item.reason}`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
lines.push("");
|
|
430
|
+
return lines.join("\n");
|
|
431
|
+
}
|
|
432
|
+
function formatReviewPacketHuman(packet) {
|
|
433
|
+
const lines = [
|
|
434
|
+
"ADMISSION REVIEW PACKET",
|
|
435
|
+
` schedule: ${packet.scheduleId}`,
|
|
436
|
+
` task: ${packet.featureId}/${packet.taskId}`,
|
|
437
|
+
` executeAt: ${packet.executeAtUtc}`,
|
|
438
|
+
` worktree: ${packet.worktreePath}`,
|
|
439
|
+
` branch: ${packet.branch}`,
|
|
440
|
+
` base: ${packet.baseBranch}@${packet.baseCommit.slice(0, 12)}`,
|
|
441
|
+
` gate: ${packet.gateToken}`,
|
|
442
|
+
` admission: ${packet.admissionPath}`,
|
|
443
|
+
];
|
|
444
|
+
if (packet.harnessTaskId)
|
|
445
|
+
lines.push(` harness: ${packet.harnessTaskId}`);
|
|
446
|
+
if (packet.taskCard)
|
|
447
|
+
lines.push(` card: ${packet.taskCard}`);
|
|
448
|
+
if (packet.writeSet?.length) {
|
|
449
|
+
lines.push(" writeSet:");
|
|
450
|
+
for (const entry of packet.writeSet)
|
|
451
|
+
lines.push(` - ${entry}`);
|
|
452
|
+
}
|
|
453
|
+
lines.push("", "Next: review writeSet, then:", ` agent-worker scheduler add ${packet.scheduleId} --approve-gate ${JSON.stringify(packet.gateToken)} --repo .`, "");
|
|
454
|
+
return lines.join("\n");
|
|
455
|
+
}
|
|
456
|
+
function formatScheduleHuman(schedule) {
|
|
457
|
+
const lines = [
|
|
458
|
+
`SCHEDULE ${schedule.id}`,
|
|
459
|
+
` task: ${schedule.featureId}/${schedule.taskId}`,
|
|
460
|
+
` status: ${schedule.status} (rev ${schedule.revision})`,
|
|
461
|
+
];
|
|
462
|
+
if (schedule.trigger?.executeAtUtc) {
|
|
463
|
+
lines.push(` plan: ${schedule.trigger.executeAtUtc}${schedule.trigger.displayTimezone ? ` (${schedule.trigger.displayTimezone})` : ""}`);
|
|
464
|
+
}
|
|
465
|
+
if (schedule.traceability?.workItemRef) {
|
|
466
|
+
lines.push(` card: ${schedule.traceability.workItemRef}`);
|
|
467
|
+
}
|
|
468
|
+
if (schedule.isolation?.worktreePath) {
|
|
469
|
+
lines.push(` worktree: ${schedule.isolation.worktreePath}`);
|
|
470
|
+
}
|
|
471
|
+
if (schedule.isolation?.mergeState) {
|
|
472
|
+
lines.push(` merge: ${schedule.isolation.mergeState}`);
|
|
473
|
+
}
|
|
474
|
+
lines.push("");
|
|
475
|
+
return `${lines.join("\n")}\n`;
|
|
476
|
+
}
|
|
477
|
+
function formatScheduleListHuman(schedules) {
|
|
478
|
+
if (schedules.length === 0) {
|
|
479
|
+
return "No schedules.\n";
|
|
480
|
+
}
|
|
481
|
+
const header = "SCHEDULE TASK PLAN TIME STATUS MERGE";
|
|
482
|
+
const lines = [header, "-".repeat(header.length)];
|
|
483
|
+
for (const schedule of schedules) {
|
|
484
|
+
const plan = schedule.trigger?.executeAtUtc ?? "-";
|
|
485
|
+
const merge = schedule.isolation?.mergeState ?? "-";
|
|
486
|
+
const task = `${schedule.featureId}/${schedule.taskId}`;
|
|
487
|
+
lines.push([
|
|
488
|
+
pad(schedule.id, 32),
|
|
489
|
+
pad(task, 25),
|
|
490
|
+
pad(plan, 24),
|
|
491
|
+
pad(schedule.status, 16),
|
|
492
|
+
merge,
|
|
493
|
+
].join(" "));
|
|
494
|
+
}
|
|
495
|
+
lines.push("");
|
|
496
|
+
return `${lines.join("\n")}\n`;
|
|
497
|
+
}
|
|
498
|
+
function formatLedgerHuman(events) {
|
|
499
|
+
if (events.length === 0)
|
|
500
|
+
return "No ledger events.\n";
|
|
501
|
+
const lines = [
|
|
502
|
+
"AT SCHEDULE TRANSITION EVENT",
|
|
503
|
+
];
|
|
504
|
+
for (const event of events) {
|
|
505
|
+
const transition = `${event.fromStatus ?? "∅"}→${event.toStatus}`;
|
|
506
|
+
lines.push([
|
|
507
|
+
pad(event.at, 25),
|
|
508
|
+
pad(event.scheduleId, 29),
|
|
509
|
+
pad(transition, 23),
|
|
510
|
+
event.event,
|
|
511
|
+
].join(" "));
|
|
512
|
+
if (event.reason) {
|
|
513
|
+
lines.push(` reason: ${event.reason}`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
lines.push("");
|
|
517
|
+
return `${lines.join("\n")}\n`;
|
|
518
|
+
}
|
|
519
|
+
function pad(value, width) {
|
|
520
|
+
if (value.length >= width)
|
|
521
|
+
return value.slice(0, width);
|
|
522
|
+
return value + " ".repeat(width - value.length);
|
|
523
|
+
}
|
|
524
|
+
function handleSchedulerCliError(error, json) {
|
|
525
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
526
|
+
const code = error instanceof SchedulerError ? error.code : "scheduler-cli-error";
|
|
527
|
+
if (json) {
|
|
528
|
+
process.stdout.write(`${JSON.stringify({
|
|
529
|
+
schemaVersion: 1,
|
|
530
|
+
ok: false,
|
|
531
|
+
code,
|
|
532
|
+
message,
|
|
533
|
+
}, null, 2)}\n`);
|
|
534
|
+
}
|
|
535
|
+
else {
|
|
536
|
+
process.stderr.write(`${message}\n`);
|
|
537
|
+
}
|
|
538
|
+
process.exitCode = 1;
|
|
539
|
+
}
|