@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.
Files changed (67) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +25 -0
  3. package/README.md +11 -1
  4. package/dist/cli/command-definitions.js +2 -1
  5. package/dist/commands/client-recovery.js +111 -8
  6. package/dist/commands/dag-init-hybrid.js +1 -1
  7. package/dist/commands/init-upgrade.js +2479 -0
  8. package/dist/commands/init.js +120 -9
  9. package/dist/governance/manifest-types.js +65 -0
  10. package/dist/shared/operator/capabilities.js +350 -2
  11. package/dist/task/worktree.js +256 -39
  12. package/dist/worker/cli.js +22 -12
  13. package/dist/worker/console/chat/workspace-landing.js +16 -6
  14. package/dist/worker/console/observe-health-match.js +2 -0
  15. package/dist/worker/console/observe-link.js +4 -0
  16. package/dist/worker/console/operator-actions.js +183 -4
  17. package/dist/worker/console/operator-selection.js +13 -0
  18. package/dist/worker/console/static/assets/index-BfRgtLF4.js +29 -0
  19. package/dist/worker/console/static/index.html +1 -1
  20. package/dist/worker/observe/health.js +1 -0
  21. package/dist/worker/observe/night-jobs.js +104 -0
  22. package/dist/worker/observe/routes.js +48 -0
  23. package/dist/worker/observe/static/app.js +3 -0
  24. package/dist/worker/observe/static/constants.js +1 -0
  25. package/dist/worker/observe/static/index.html +47 -0
  26. package/dist/worker/observe/static/router.js +10 -0
  27. package/dist/worker/observe/static/shell-chrome.js +1 -0
  28. package/dist/worker/observe/static/views/night.js +201 -0
  29. package/dist/worker/report/morning-report.js +56 -16
  30. package/dist/worker/run-task/execute-prepared-task.js +153 -0
  31. package/dist/worker/runner/single-task-attempt.js +147 -0
  32. package/dist/worker/scheduler/admission.js +536 -0
  33. package/dist/worker/scheduler/auto-followup.js +99 -0
  34. package/dist/worker/scheduler/cli.js +539 -0
  35. package/dist/worker/scheduler/dispatcher.js +503 -0
  36. package/dist/worker/scheduler/doctor.js +346 -0
  37. package/dist/worker/scheduler/evidence.js +170 -0
  38. package/dist/worker/scheduler/git-base.js +52 -0
  39. package/dist/worker/scheduler/index.js +23 -0
  40. package/dist/worker/scheduler/lease.js +114 -0
  41. package/dist/worker/scheduler/lifecycle.js +348 -0
  42. package/dist/worker/scheduler/lock.js +80 -0
  43. package/dist/worker/scheduler/morning-window.js +161 -0
  44. package/dist/worker/scheduler/night-git-finalizer.js +88 -0
  45. package/dist/worker/scheduler/night-harvest.js +421 -0
  46. package/dist/worker/scheduler/paths.js +84 -0
  47. package/dist/worker/scheduler/prepared-attempt-recovery.js +471 -0
  48. package/dist/worker/scheduler/recovery.js +277 -0
  49. package/dist/worker/scheduler/reservation.js +146 -0
  50. package/dist/worker/scheduler/retry.js +199 -0
  51. package/dist/worker/scheduler/scheduler-loop.js +272 -0
  52. package/dist/worker/scheduler/store.js +275 -0
  53. package/dist/worker/scheduler/traceability.js +54 -0
  54. package/dist/worker/scheduler/trigger.js +258 -0
  55. package/dist/worker/scheduler/types.js +369 -0
  56. package/dist/worker/scheduler/workspace-adapter.js +91 -0
  57. package/dist/workflows/dag/frontend-implementation-contract.js +2 -102
  58. package/docs/architecture/runtime-boundaries.md +9 -0
  59. package/docs/init-surface.manifest.json +9 -2
  60. package/docs/templates/harness.schema.json +107 -0
  61. package/docs/templates/init-managed-agents.md +18 -8
  62. package/harness.json +22 -0
  63. package/package.json +1 -1
  64. package/skills/loop-agent/SKILL.md +28 -36
  65. package/skills/loop-agent/references/command-reference.md +40 -16
  66. package/skills/loop-agent/references/hybrid-dag.md +1 -1
  67. package/dist/worker/console/static/assets/index-CNO7n6qB.js +0 -29
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Pure time helpers for once schedules, allowedHours windows, and misfire policy.
3
+ * No I/O. Display timezone is IANA; executeAt is always frozen as UTC ISO.
4
+ */
5
+ const HHMM = /^(\d{2}):(\d{2})$/;
6
+ const LOCAL_AT = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?$/;
7
+ /**
8
+ * Parse a local wall-clock time in an IANA timezone into a unique UTC instant.
9
+ * Fail-closed on ambiguous/invalid inputs rather than guessing.
10
+ */
11
+ export function parseOnceLocalToUtc(input) {
12
+ const timezone = input.timezone.trim();
13
+ if (!timezone) {
14
+ throw new Error("timezone is required");
15
+ }
16
+ assertValidTimezone(timezone);
17
+ const match = LOCAL_AT.exec(input.localAt.trim());
18
+ if (!match) {
19
+ throw new Error(`invalid localAt (expected YYYY-MM-DD HH:mm): ${JSON.stringify(input.localAt)}`);
20
+ }
21
+ const year = Number(match[1]);
22
+ const month = Number(match[2]);
23
+ const day = Number(match[3]);
24
+ const hour = Number(match[4]);
25
+ const minute = Number(match[5]);
26
+ const second = Number(match[6] ?? "0");
27
+ const utcMs = zonedLocalToUtcMs({
28
+ year,
29
+ month,
30
+ day,
31
+ hour,
32
+ minute,
33
+ second,
34
+ timezone,
35
+ });
36
+ if (utcMs === null) {
37
+ throw new Error(`cannot resolve localAt ${JSON.stringify(input.localAt)} in timezone ${timezone}`);
38
+ }
39
+ const executeAtUtc = new Date(utcMs).toISOString();
40
+ const misfirePolicy = input.misfirePolicy ?? "wait-next-window";
41
+ const result = {
42
+ type: "once",
43
+ executeAtUtc,
44
+ displayTimezone: timezone,
45
+ misfirePolicy,
46
+ };
47
+ if (input.latestStartAtUtc) {
48
+ const latest = Date.parse(input.latestStartAtUtc);
49
+ if (!Number.isFinite(latest)) {
50
+ throw new Error(`invalid latestStartAtUtc: ${JSON.stringify(input.latestStartAtUtc)}`);
51
+ }
52
+ if (latest < utcMs) {
53
+ throw new Error("latestStartAtUtc must be >= executeAtUtc");
54
+ }
55
+ result.latestStartAtUtc = new Date(latest).toISOString();
56
+ }
57
+ return result;
58
+ }
59
+ export function isWithinAllowedHours(nowUtc, timezone, allowedHours) {
60
+ if (allowedHours.length === 0)
61
+ return true;
62
+ const minutes = localMinutesSinceMidnight(nowUtc, timezone);
63
+ return allowedHours.some((window) => minutesInWindow(minutes, window));
64
+ }
65
+ export function nextAllowedWindowStartUtc(nowUtc, timezone, allowedHours) {
66
+ if (allowedHours.length === 0)
67
+ return nowUtc.toISOString();
68
+ const windows = allowedHours
69
+ .map(parseWindow)
70
+ .sort((a, b) => a.start - b.start);
71
+ // Search the next 48 hours in 1-minute steps (deterministic, no DST edge guessing beyond Intl).
72
+ for (let offsetMin = 0; offsetMin < 60 * 48; offsetMin += 1) {
73
+ const candidate = new Date(nowUtc.getTime() + offsetMin * 60_000);
74
+ const minutes = localMinutesSinceMidnight(candidate, timezone);
75
+ if (windows.some((window) => minutesInParsedWindow(minutes, window))) {
76
+ return candidate.toISOString();
77
+ }
78
+ }
79
+ return null;
80
+ }
81
+ export function evaluateOnceDue(input) {
82
+ const executeAt = Date.parse(input.executeAtUtc);
83
+ if (!Number.isFinite(executeAt)) {
84
+ return {
85
+ kind: "human_required",
86
+ reason: `invalid executeAtUtc: ${input.executeAtUtc}`,
87
+ };
88
+ }
89
+ const nowMs = input.nowUtc.getTime();
90
+ if (nowMs < executeAt) {
91
+ return {
92
+ kind: "waiting",
93
+ reason: "not-due",
94
+ nextAttemptAt: input.executeAtUtc,
95
+ };
96
+ }
97
+ if (input.latestStartAtUtc) {
98
+ const latest = Date.parse(input.latestStartAtUtc);
99
+ if (Number.isFinite(latest) && nowMs > latest) {
100
+ return {
101
+ kind: "human_required",
102
+ reason: "past-latest-start",
103
+ };
104
+ }
105
+ }
106
+ const inWindow = isWithinAllowedHours(input.nowUtc, input.timezone, input.allowedHours);
107
+ if (inWindow) {
108
+ return { kind: "eligible" };
109
+ }
110
+ if (input.misfirePolicy === "run-immediately") {
111
+ // Still respect allowedHours when policy says wait/human; run-immediately
112
+ // only bypasses window if we are not past latestStart.
113
+ return { kind: "eligible" };
114
+ }
115
+ if (input.misfirePolicy === "human-required") {
116
+ return {
117
+ kind: "human_required",
118
+ reason: "outside-allowed-hours",
119
+ };
120
+ }
121
+ const next = nextAllowedWindowStartUtc(input.nowUtc, input.timezone, input.allowedHours);
122
+ if (!next) {
123
+ return {
124
+ kind: "human_required",
125
+ reason: "no-next-allowed-window",
126
+ };
127
+ }
128
+ return {
129
+ kind: "waiting",
130
+ reason: "outside-allowed-hours",
131
+ nextAttemptAt: next,
132
+ };
133
+ }
134
+ export function stableDueSortKey(schedule) {
135
+ return `${schedule.executeAtUtc}\0${schedule.scheduleId}`;
136
+ }
137
+ export function compareDueSchedules(a, b) {
138
+ return stableDueSortKey(a).localeCompare(stableDueSortKey(b));
139
+ }
140
+ function assertValidTimezone(timezone) {
141
+ try {
142
+ // Throws RangeError for invalid IANA ids in modern Node.
143
+ new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format(0);
144
+ }
145
+ catch {
146
+ throw new Error(`invalid timezone: ${JSON.stringify(timezone)}`);
147
+ }
148
+ }
149
+ function localMinutesSinceMidnight(date, timezone) {
150
+ const parts = new Intl.DateTimeFormat("en-US", {
151
+ timeZone: timezone,
152
+ hour: "2-digit",
153
+ minute: "2-digit",
154
+ hourCycle: "h23",
155
+ }).formatToParts(date);
156
+ const hour = Number(parts.find((part) => part.type === "hour")?.value ?? "0");
157
+ const minute = Number(parts.find((part) => part.type === "minute")?.value ?? "0");
158
+ return hour * 60 + minute;
159
+ }
160
+ function parseWindow(window) {
161
+ const [startRaw, endRaw] = window.split("-");
162
+ if (!startRaw || !endRaw) {
163
+ throw new Error(`invalid allowedHours window: ${JSON.stringify(window)}`);
164
+ }
165
+ return {
166
+ start: parseHhMm(startRaw),
167
+ end: parseHhMm(endRaw),
168
+ };
169
+ }
170
+ function parseHhMm(value) {
171
+ const match = HHMM.exec(value);
172
+ if (!match) {
173
+ throw new Error(`invalid HH:MM: ${JSON.stringify(value)}`);
174
+ }
175
+ const hour = Number(match[1]);
176
+ const minute = Number(match[2]);
177
+ if (hour > 23 || minute > 59) {
178
+ throw new Error(`invalid HH:MM: ${JSON.stringify(value)}`);
179
+ }
180
+ return hour * 60 + minute;
181
+ }
182
+ function minutesInWindow(minutes, window) {
183
+ return minutesInParsedWindow(minutes, parseWindow(window));
184
+ }
185
+ function minutesInParsedWindow(minutes, window) {
186
+ if (window.start === window.end)
187
+ return true;
188
+ if (window.start < window.end) {
189
+ return minutes >= window.start && minutes < window.end;
190
+ }
191
+ // Cross midnight, e.g. 22:00-06:00
192
+ return minutes >= window.start || minutes < window.end;
193
+ }
194
+ /**
195
+ * Convert a local civil time in `timezone` to UTC ms using iterative offset
196
+ * resolution (handles DST without external deps).
197
+ */
198
+ function zonedLocalToUtcMs(input) {
199
+ const utcGuess = Date.UTC(input.year, input.month - 1, input.day, input.hour, input.minute, input.second);
200
+ for (let i = 0; i < 3; i += 1) {
201
+ const offset = timezoneOffsetMs(new Date(utcGuess), input.timezone);
202
+ const candidate = utcGuess - offset;
203
+ const roundTrip = timezoneOffsetMs(new Date(candidate), input.timezone);
204
+ if (roundTrip === offset) {
205
+ // Verify the local components match.
206
+ const parts = getZonedParts(new Date(candidate), input.timezone);
207
+ if (parts.year === input.year &&
208
+ parts.month === input.month &&
209
+ parts.day === input.day &&
210
+ parts.hour === input.hour &&
211
+ parts.minute === input.minute &&
212
+ parts.second === input.second) {
213
+ return candidate;
214
+ }
215
+ }
216
+ }
217
+ // Fallback: try neighboring hour for DST folds.
218
+ for (const delta of [-3600_000, 3600_000, -7200_000, 7200_000]) {
219
+ const candidate = utcGuess + delta;
220
+ const parts = getZonedParts(new Date(candidate), input.timezone);
221
+ if (parts.year === input.year &&
222
+ parts.month === input.month &&
223
+ parts.day === input.day &&
224
+ parts.hour === input.hour &&
225
+ parts.minute === input.minute &&
226
+ parts.second === input.second) {
227
+ return candidate;
228
+ }
229
+ }
230
+ return null;
231
+ }
232
+ function timezoneOffsetMs(date, timezone) {
233
+ const parts = getZonedParts(date, timezone);
234
+ const asUtc = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
235
+ return asUtc - date.getTime();
236
+ }
237
+ function getZonedParts(date, timezone) {
238
+ const dtf = new Intl.DateTimeFormat("en-US", {
239
+ timeZone: timezone,
240
+ year: "numeric",
241
+ month: "2-digit",
242
+ day: "2-digit",
243
+ hour: "2-digit",
244
+ minute: "2-digit",
245
+ second: "2-digit",
246
+ hourCycle: "h23",
247
+ });
248
+ const parts = dtf.formatToParts(date);
249
+ const read = (type) => Number(parts.find((part) => part.type === type)?.value ?? "0");
250
+ return {
251
+ year: read("year"),
252
+ month: read("month"),
253
+ day: read("day"),
254
+ hour: read("hour"),
255
+ minute: read("minute"),
256
+ second: read("second"),
257
+ };
258
+ }
@@ -0,0 +1,369 @@
1
+ import { z } from "zod";
2
+ export const SCHEDULE_SCHEMA_VERSION = 1;
3
+ export const ADMISSION_SCHEMA_VERSION = 1;
4
+ export const EXECUTION_SCHEMA_VERSION = 1;
5
+ export const LEDGER_SCHEMA_VERSION = 1;
6
+ export const TRANSACTION_SCHEMA_VERSION = 1;
7
+ export const scheduleStatusSchema = z.enum([
8
+ "submitted",
9
+ "validating",
10
+ "rejected",
11
+ "validated",
12
+ "scheduled",
13
+ "waiting",
14
+ "dispatching",
15
+ "running",
16
+ "cancel_requested",
17
+ "succeeded",
18
+ "failed",
19
+ "human_required",
20
+ "cancelled",
21
+ ]);
22
+ export const TERMINAL_SCHEDULE_STATUSES = new Set([
23
+ "rejected",
24
+ "succeeded",
25
+ "failed",
26
+ "human_required",
27
+ "cancelled",
28
+ ]);
29
+ export const ACTIVE_SCHEDULE_STATUSES = new Set(scheduleStatusSchema.options.filter((status) => !TERMINAL_SCHEDULE_STATUSES.has(status)));
30
+ export const scheduleLedgerEventNameSchema = z.enum([
31
+ "plan_submitted",
32
+ "validation_started",
33
+ "validation_passed",
34
+ "validation_rejected",
35
+ "task_reserved",
36
+ "schedule_created",
37
+ "schedule_cancelled",
38
+ "became_due",
39
+ "waiting",
40
+ "execution_claimed",
41
+ "dispatch_started",
42
+ "worker_run_associated",
43
+ "execution_finished",
44
+ "evidence_archived",
45
+ "closing_commit_created",
46
+ "merge_skipped",
47
+ "merge_completed",
48
+ "worktree_discarded",
49
+ "cancel_requested",
50
+ "human_required",
51
+ "doctor_finding",
52
+ "transition_recovered",
53
+ "lease_recovered",
54
+ "auto_retry_queued",
55
+ "auto_followup_enqueued",
56
+ ]);
57
+ export const scheduleTriggerSchema = z.object({
58
+ type: z.literal("once"),
59
+ executeAtUtc: z.string().datetime(),
60
+ displayTimezone: z.string().min(1),
61
+ latestStartAtUtc: z.string().datetime().optional(),
62
+ misfirePolicy: z
63
+ .enum(["run-immediately", "wait-next-window", "human-required"])
64
+ .optional(),
65
+ });
66
+ export const schedulePolicySnapshotSchema = z.object({
67
+ allowedHours: z.array(z.string().min(1)).default(["00:00-08:00"]),
68
+ maxConcurrency: z.number().int().positive().default(1),
69
+ maxAutoAttempts: z.number().int().positive().default(1),
70
+ keepFailedWorktree: z.boolean().default(true),
71
+ mergePolicy: z
72
+ .enum(["manual-on-success", "auto-on-success"])
73
+ .default("manual-on-success"),
74
+ misfirePolicy: z
75
+ .enum(["run-immediately", "wait-next-window", "human-required"])
76
+ .default("wait-next-window"),
77
+ timezone: z.string().min(1).default("Asia/Shanghai"),
78
+ /** ADR 0010: frozen at admission; default false. */
79
+ autoFollowUpEnabled: z.boolean().default(false),
80
+ autoFollowUpMode: z.enum(["draft-only"]).default("draft-only"),
81
+ });
82
+ export const scheduleIsolationSchema = z.object({
83
+ mode: z.literal("git-worktree").default("git-worktree"),
84
+ required: z.boolean().default(true),
85
+ baseBranch: z.string().min(1).optional(),
86
+ baseCommit: z.string().min(1).optional(),
87
+ worktreePath: z.string().min(1).optional(),
88
+ branch: z.string().min(1).optional(),
89
+ mergePolicy: z
90
+ .enum(["manual-on-success", "auto-on-success"])
91
+ .default("manual-on-success"),
92
+ mergeState: z
93
+ .enum([
94
+ "not-applicable",
95
+ "pending-execution",
96
+ "pending-harvest",
97
+ "merged",
98
+ "discarded",
99
+ "not-merged",
100
+ ])
101
+ .default("not-applicable"),
102
+ });
103
+ export const scheduleTraceabilitySchema = z.object({
104
+ kind: z.literal("task-card").default("task-card"),
105
+ label: z.string().min(1).default("Task Card"),
106
+ workItemRef: z.string().min(1).optional(),
107
+ pattern: z.string().min(1).optional(),
108
+ commitMessageTemplate: z.string().min(1).optional(),
109
+ requiredOnCreate: z.boolean().default(true),
110
+ requiredOnEveryPushableCommit: z.boolean().default(true),
111
+ });
112
+ export const scheduleTimestampsSchema = z.object({
113
+ submittedAt: z.string().datetime(),
114
+ validatedAt: z.string().datetime().nullable().optional(),
115
+ scheduledAt: z.string().datetime().nullable().optional(),
116
+ startedAt: z.string().datetime().nullable().optional(),
117
+ finishedAt: z.string().datetime().nullable().optional(),
118
+ });
119
+ export const scheduleRecordSchema = z.object({
120
+ schemaVersion: z.literal(SCHEDULE_SCHEMA_VERSION),
121
+ id: z.string().min(1),
122
+ revision: z.number().int().nonnegative(),
123
+ featureId: z.string().min(1),
124
+ taskId: z.string().min(1),
125
+ featureDir: z.string().min(1).optional(),
126
+ status: scheduleStatusSchema,
127
+ dispatch: z
128
+ .object({
129
+ mode: z.literal("single-task").default("single-task"),
130
+ })
131
+ .default({ mode: "single-task" }),
132
+ trigger: scheduleTriggerSchema.optional(),
133
+ policySnapshot: schedulePolicySnapshotSchema.optional(),
134
+ admissionRef: z.string().min(1).optional(),
135
+ reservation: z
136
+ .object({
137
+ taskPoolStatus: z.literal("Queued"),
138
+ reservedAt: z.string().datetime(),
139
+ })
140
+ .optional(),
141
+ isolation: scheduleIsolationSchema.optional(),
142
+ traceability: scheduleTraceabilitySchema.optional(),
143
+ timestamps: scheduleTimestampsSchema,
144
+ currentExecutionId: z.string().min(1).nullable().optional(),
145
+ lastTransitionId: z.string().min(1).optional(),
146
+ lastLedgerEventId: z.string().min(1).optional(),
147
+ waitingReason: z.string().nullable().optional(),
148
+ nextAttemptAt: z.string().datetime().nullable().optional(),
149
+ cancelReason: z.string().nullable().optional(),
150
+ rejectReason: z.string().nullable().optional(),
151
+ });
152
+ export const taskAdmissionSchema = z.object({
153
+ schemaVersion: z.literal(ADMISSION_SCHEMA_VERSION),
154
+ scheduleId: z.string().min(1),
155
+ featureId: z.string().min(1),
156
+ taskId: z.string().min(1),
157
+ featureDir: z.string().min(1),
158
+ taskSpec: z
159
+ .object({
160
+ path: z.string().min(1),
161
+ sha256: z.string().min(1),
162
+ })
163
+ .optional(),
164
+ taskGraph: z
165
+ .object({
166
+ path: z.string().min(1),
167
+ sha256: z.string().min(1),
168
+ })
169
+ .optional(),
170
+ harnessTaskId: z.string().min(1).optional(),
171
+ workspace: z
172
+ .object({
173
+ path: z.string().min(1),
174
+ branch: z.string().min(1),
175
+ baseBranch: z.string().min(1),
176
+ baseCommit: z.string().min(1),
177
+ ownershipToken: z.string().min(1),
178
+ taskDir: z.string().min(1).optional(),
179
+ })
180
+ .optional(),
181
+ controllerIdentity: z.record(z.string(), z.unknown()).optional(),
182
+ dag: z
183
+ .object({
184
+ path: z.string().min(1),
185
+ sha256: z.string().min(1),
186
+ nodeCount: z.number().int().nonnegative().optional(),
187
+ strictValidated: z.boolean().optional(),
188
+ writeSet: z.array(z.string()).optional(),
189
+ })
190
+ .optional(),
191
+ gateApproval: z
192
+ .object({
193
+ id: z.string().min(1),
194
+ token: z.string().min(1),
195
+ digest: z.string().min(1),
196
+ bound: z.record(z.string(), z.unknown()).optional(),
197
+ approvedAt: z.string().datetime().optional(),
198
+ approvedBy: z.string().min(1).optional(),
199
+ })
200
+ .optional(),
201
+ verification: z
202
+ .object({
203
+ commands: z.array(z.object({
204
+ label: z.string().min(1),
205
+ command: z.string().min(1),
206
+ })),
207
+ })
208
+ .optional(),
209
+ estimate: z
210
+ .object({
211
+ minutes: z.number().nonnegative().optional(),
212
+ confidence: z.string().optional(),
213
+ })
214
+ .optional(),
215
+ risk: z.string().optional(),
216
+ createdAt: z.string().datetime(),
217
+ });
218
+ export const executionRecordSchema = z.object({
219
+ schemaVersion: z.literal(EXECUTION_SCHEMA_VERSION),
220
+ executionId: z.string().min(1),
221
+ scheduleId: z.string().min(1),
222
+ attempt: z.number().int().positive(),
223
+ status: z.enum([
224
+ "claimed",
225
+ "running",
226
+ "succeeded",
227
+ "failed",
228
+ "human_required",
229
+ "cancelled",
230
+ ]),
231
+ claimedAt: z.string().datetime(),
232
+ startedAt: z.string().datetime().nullable().optional(),
233
+ finishedAt: z.string().datetime().nullable().optional(),
234
+ lease: z
235
+ .object({
236
+ ownerPid: z.number().int().positive(),
237
+ hostname: z.string().min(1),
238
+ token: z.string().min(1),
239
+ heartbeatAt: z.string().datetime(),
240
+ })
241
+ .optional(),
242
+ workerRunId: z.string().min(1).optional(),
243
+ dagRunId: z.string().min(1).optional(),
244
+ workspacePath: z.string().min(1).optional(),
245
+ evidenceArchivePath: z.string().min(1).optional(),
246
+ failure: z
247
+ .object({
248
+ code: z.string().optional(),
249
+ message: z.string().optional(),
250
+ })
251
+ .nullable()
252
+ .optional(),
253
+ });
254
+ export const ledgerEventSchema = z.object({
255
+ schemaVersion: z.literal(LEDGER_SCHEMA_VERSION),
256
+ eventId: z.string().min(1),
257
+ transitionId: z.string().min(1),
258
+ revision: z.number().int().nonnegative(),
259
+ at: z.string().datetime(),
260
+ scheduleId: z.string().min(1),
261
+ featureId: z.string().min(1),
262
+ taskId: z.string().min(1),
263
+ fromStatus: scheduleStatusSchema.nullable(),
264
+ toStatus: scheduleStatusSchema,
265
+ event: scheduleLedgerEventNameSchema,
266
+ plannedExecuteAtUtc: z.string().datetime().optional(),
267
+ reasonCode: z.string().nullable().optional(),
268
+ reason: z.string().nullable().optional(),
269
+ executionId: z.string().min(1).nullable().optional(),
270
+ worktreePath: z.string().min(1).nullable().optional(),
271
+ evidencePaths: z.array(z.string()).optional(),
272
+ });
273
+ export const transitionJournalSchema = z.object({
274
+ schemaVersion: z.literal(TRANSACTION_SCHEMA_VERSION),
275
+ transitionId: z.string().min(1),
276
+ scheduleId: z.string().min(1),
277
+ status: z.enum(["pending", "committed"]),
278
+ createdAt: z.string().datetime(),
279
+ committedAt: z.string().datetime().optional(),
280
+ /** Prior schedule revision; null means create (no prior snapshot). */
281
+ expectedRevision: z.number().int().nonnegative().nullable(),
282
+ fromStatus: scheduleStatusSchema.nullable(),
283
+ toStatus: scheduleStatusSchema,
284
+ event: scheduleLedgerEventNameSchema,
285
+ patch: z.record(z.string(), z.unknown()).default({}),
286
+ ledgerEvent: ledgerEventSchema,
287
+ scheduleSnapshot: scheduleRecordSchema,
288
+ });
289
+ export const SCHEDULER_ERROR_CODES = {
290
+ NOT_FOUND: "scheduler-not-found",
291
+ INVALID_TRANSITION: "scheduler-invalid-transition",
292
+ REVISION_CONFLICT: "scheduler-revision-conflict",
293
+ DUPLICATE_ACTIVE: "scheduler-duplicate-active",
294
+ INVALID_RECORD: "scheduler-invalid-record",
295
+ LOCK_HELD: "scheduler-lock-held",
296
+ UNSAFE_ID: "scheduler-unsafe-id",
297
+ JOURNAL_PENDING: "scheduler-journal-pending",
298
+ TERMINAL: "scheduler-terminal",
299
+ };
300
+ export class SchedulerError extends Error {
301
+ code;
302
+ constructor(code, message) {
303
+ super(message);
304
+ this.name = "SchedulerError";
305
+ this.code = code;
306
+ }
307
+ }
308
+ /** Legal from → to edges for Phase 1 lifecycle. */
309
+ export const SCHEDULE_TRANSITIONS = {
310
+ submitted: ["validating", "rejected", "cancelled"],
311
+ validating: ["validated", "rejected", "cancelled"],
312
+ rejected: [],
313
+ validated: ["scheduled", "rejected", "cancelled"],
314
+ scheduled: ["waiting", "dispatching", "cancelled", "human_required"],
315
+ waiting: ["dispatching", "cancelled", "human_required", "waiting"],
316
+ dispatching: ["running", "waiting", "failed", "human_required", "cancelled"],
317
+ running: [
318
+ "succeeded",
319
+ "failed",
320
+ "human_required",
321
+ "cancel_requested",
322
+ "cancelled",
323
+ ],
324
+ cancel_requested: ["cancelled", "failed", "human_required"],
325
+ succeeded: [],
326
+ // failed is normally terminal; Phase 4 allows failed → waiting only for
327
+ // bounded EnvFailure auto-retry (never expands writeSet / never re-admits).
328
+ failed: ["waiting"],
329
+ human_required: [],
330
+ cancelled: [],
331
+ };
332
+ export function isTerminalScheduleStatus(status) {
333
+ return TERMINAL_SCHEDULE_STATUSES.has(status);
334
+ }
335
+ export function canTransitionSchedule(from, to) {
336
+ if (from === null) {
337
+ return to === "submitted";
338
+ }
339
+ return (SCHEDULE_TRANSITIONS[from] ?? []).includes(to);
340
+ }
341
+ export function defaultEventForTransition(from, to) {
342
+ if (from === null && to === "submitted")
343
+ return "plan_submitted";
344
+ if (to === "validating")
345
+ return "validation_started";
346
+ if (to === "validated")
347
+ return "validation_passed";
348
+ if (to === "rejected")
349
+ return "validation_rejected";
350
+ if (to === "scheduled")
351
+ return "schedule_created";
352
+ if (from === "failed" && to === "waiting")
353
+ return "auto_retry_queued";
354
+ if (to === "waiting")
355
+ return "waiting";
356
+ if (to === "dispatching")
357
+ return "execution_claimed";
358
+ if (to === "running")
359
+ return "worker_run_associated";
360
+ if (to === "succeeded" || to === "failed")
361
+ return "execution_finished";
362
+ if (to === "human_required")
363
+ return "human_required";
364
+ if (to === "cancel_requested")
365
+ return "cancel_requested";
366
+ if (to === "cancelled")
367
+ return "schedule_cancelled";
368
+ return "doctor_finding";
369
+ }