@tea-agent/loop-agent 0.28.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 (66) 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/docs/architecture/runtime-boundaries.md +9 -0
  58. package/docs/init-surface.manifest.json +9 -2
  59. package/docs/templates/harness.schema.json +107 -0
  60. package/docs/templates/init-managed-agents.md +18 -8
  61. package/harness.json +22 -0
  62. package/package.json +1 -1
  63. package/skills/loop-agent/SKILL.md +28 -36
  64. package/skills/loop-agent/references/command-reference.md +40 -16
  65. package/skills/loop-agent/references/hybrid-dag.md +1 -1
  66. package/dist/worker/console/static/assets/index-CNO7n6qB.js +0 -29
@@ -0,0 +1,536 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import YAML from "yaml";
5
+ import { nightSchedulerPolicySchema, } from "../../governance/manifest-types.js";
6
+ import { loadHarnessManifest } from "../../governance/harness.js";
7
+ import { materializeTaskSpec, } from "../materialize/harness-task-materializer.js";
8
+ import { readTaskPoolState } from "../pool/run-store.js";
9
+ import { resolveControllerIdentity, controllerIdentityExpectationFailure, } from "../loop-agent/loop-agent-client.js";
10
+ import { taskSpecSchema } from "../task-spec/schema.js";
11
+ import { getTaskPaths } from "../../task/runtime.js";
12
+ import { freezeCleanBase } from "./git-base.js";
13
+ import { cancelSchedule, submitSchedule, transitionSchedule, } from "./lifecycle.js";
14
+ import { assertSafeSchedulerId, getAdmissionPath, relativeSchedulerPath, } from "./paths.js";
15
+ import { reserveTaskPoolForSchedule, releaseTaskPoolReservation, } from "./reservation.js";
16
+ import { readAdmission, readSchedule, writeAdmission } from "./store.js";
17
+ import { validateTaskCard } from "./traceability.js";
18
+ import { SCHEDULER_ERROR_CODES, SchedulerError, } from "./types.js";
19
+ import { parseOnceLocalToUtc } from "./trigger.js";
20
+ import { prepareNightWorkspace } from "./workspace-adapter.js";
21
+ /**
22
+ * Daytime admission: submitted → validating → worktree + materialize + gate → validated.
23
+ * Does NOT execute DAG and does NOT consume approve-gate.
24
+ */
25
+ export async function prepareAdmission(input) {
26
+ const now = input.now ?? new Date();
27
+ const controlRepoRoot = path.resolve(input.controlRepoRoot);
28
+ const featureDir = path.isAbsolute(input.featureDir)
29
+ ? input.featureDir
30
+ : path.resolve(controlRepoRoot, input.featureDir);
31
+ const featureDirRelative = path.relative(controlRepoRoot, featureDir);
32
+ if (featureDirRelative.length === 0 ||
33
+ featureDirRelative.startsWith("..") ||
34
+ path.isAbsolute(featureDirRelative)) {
35
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.INVALID_RECORD, `featureDir must be a child of controlRepoRoot: ${input.featureDir}`);
36
+ }
37
+ const relativeFeatureDir = featureDirRelative.split(path.sep).join("/");
38
+ assertSafeSchedulerId(input.taskId, "taskId");
39
+ const policy = await resolveNightPolicy(controlRepoRoot, input.policy);
40
+ if (!policy.enabled) {
41
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.INVALID_RECORD, "Night Scheduler is disabled by workerPolicy.nightScheduler.enabled");
42
+ }
43
+ const card = validateTaskCard(input.taskCard, policy.traceability);
44
+ if (!card.ok) {
45
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.INVALID_RECORD, card.reason);
46
+ }
47
+ const trigger = parseOnceLocalToUtc({
48
+ localAt: input.at,
49
+ timezone: input.timezone ?? policy.timezone,
50
+ misfirePolicy: policy.misfirePolicy,
51
+ });
52
+ // Load TaskSpec from feature dir before creating schedule so we know featureId.
53
+ const { taskSpec, taskSpecPath, taskSpecSha, taskGraphPath, taskGraphSha, taskGraphDependencies, } = await loadFeatureTask(featureDir, input.taskId);
54
+ for (const dependencyId of taskGraphDependencies) {
55
+ const dependencyState = await readTaskPoolState(controlRepoRoot, {
56
+ featureId: taskSpec.feature_id,
57
+ taskId: dependencyId,
58
+ });
59
+ if (dependencyState?.status !== "Done") {
60
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.INVALID_RECORD, `dependency ${dependencyId} is not Done for ${taskSpec.id}`);
61
+ }
62
+ }
63
+ if (taskSpec.feature_id &&
64
+ path.basename(featureDir) !== taskSpec.feature_id) {
65
+ // Soft check: feature dir basename should match feature_id when conventional.
66
+ }
67
+ const submitted = await submitSchedule({
68
+ controlRepoRoot,
69
+ featureId: taskSpec.feature_id,
70
+ taskId: taskSpec.id,
71
+ featureDir: relativeFeatureDir,
72
+ workItemRef: card.workItemRef || undefined,
73
+ trigger: {
74
+ type: "once",
75
+ executeAtUtc: trigger.executeAtUtc,
76
+ latestStartAtUtc: trigger.latestStartAtUtc ??
77
+ new Date(Date.parse(trigger.executeAtUtc) + 24 * 60 * 60_000).toISOString(),
78
+ displayTimezone: trigger.displayTimezone,
79
+ misfirePolicy: trigger.misfirePolicy,
80
+ },
81
+ policySnapshot: {
82
+ allowedHours: policy.allowedHours,
83
+ maxConcurrency: policy.maxConcurrency,
84
+ maxAutoAttempts: policy.maxAutoAttempts,
85
+ keepFailedWorktree: policy.keepFailedWorktree,
86
+ mergePolicy: policy.mergePolicy,
87
+ misfirePolicy: policy.misfirePolicy,
88
+ timezone: policy.timezone,
89
+ autoFollowUpEnabled: policy.autoFollowUp?.enabled === true,
90
+ autoFollowUpMode: policy.autoFollowUp?.mode ?? "draft-only",
91
+ },
92
+ traceability: {
93
+ kind: "task-card",
94
+ label: policy.traceability.label,
95
+ workItemRef: card.workItemRef || undefined,
96
+ pattern: policy.traceability.pattern,
97
+ commitMessageTemplate: policy.traceability.commitMessageTemplate,
98
+ requiredOnCreate: policy.traceability.required,
99
+ requiredOnEveryPushableCommit: policy.traceability.required,
100
+ },
101
+ now,
102
+ });
103
+ const scheduleId = submitted.schedule.id;
104
+ try {
105
+ await transitionSchedule({
106
+ controlRepoRoot,
107
+ scheduleId,
108
+ toStatus: "validating",
109
+ now,
110
+ });
111
+ const baseFactory = input.baseFactory ?? freezeCleanBase;
112
+ const base = await baseFactory(controlRepoRoot);
113
+ const workspaceFactory = input.workspaceFactory ?? prepareNightWorkspace;
114
+ const workspace = await workspaceFactory({
115
+ controlRepoRoot,
116
+ scheduleId,
117
+ baseBranch: base.baseBranch,
118
+ baseCommit: base.baseCommit,
119
+ now,
120
+ });
121
+ const prepareInWorkspace = input.prepareInWorkspace ?? defaultPrepareInWorkspace;
122
+ const prepared = await prepareInWorkspace({
123
+ workspaceRoot: workspace.path,
124
+ controlRepoRoot,
125
+ taskSpec,
126
+ taskSpecPath,
127
+ client: input.client,
128
+ now,
129
+ });
130
+ const identity = resolveControllerIdentity(input.client, prepared.controllerIdentity) ??
131
+ prepared.controllerIdentity;
132
+ const identityFailure = controllerIdentityExpectationFailure(identity, input.controllerExpectation);
133
+ if (identityFailure) {
134
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.INVALID_RECORD, `${identityFailure.code}: ${identityFailure.message}`);
135
+ }
136
+ const admission = {
137
+ schemaVersion: 1,
138
+ scheduleId,
139
+ featureId: taskSpec.feature_id,
140
+ taskId: taskSpec.id,
141
+ featureDir: relativeFeatureDir,
142
+ taskSpec: {
143
+ path: path
144
+ .relative(controlRepoRoot, taskSpecPath)
145
+ .split(path.sep)
146
+ .join("/"),
147
+ sha256: taskSpecSha,
148
+ },
149
+ ...(taskGraphPath
150
+ ? {
151
+ taskGraph: {
152
+ path: path
153
+ .relative(controlRepoRoot, taskGraphPath)
154
+ .split(path.sep)
155
+ .join("/"),
156
+ sha256: taskGraphSha ?? "sha256:unknown",
157
+ },
158
+ }
159
+ : {}),
160
+ harnessTaskId: prepared.materializeManifest.harnessTaskId,
161
+ workspace: {
162
+ path: workspace.relativeWorktreePath,
163
+ branch: workspace.relativeBranch,
164
+ baseBranch: base.baseBranch,
165
+ baseCommit: base.baseCommit,
166
+ ownershipToken: workspace.ownershipToken,
167
+ taskDir: `.harness/tasks/${prepared.materializeManifest.harnessTaskId}`,
168
+ },
169
+ ...(identity
170
+ ? { controllerIdentity: identity }
171
+ : {}),
172
+ dag: {
173
+ path: path
174
+ .relative(workspace.path, prepared.dagPath)
175
+ .split(path.sep)
176
+ .join("/"),
177
+ sha256: prepared.dagSha256,
178
+ ...(prepared.nodeCount !== undefined
179
+ ? { nodeCount: prepared.nodeCount }
180
+ : {}),
181
+ strictValidated: true,
182
+ ...(prepared.writeSet ? { writeSet: prepared.writeSet } : {}),
183
+ },
184
+ gateApproval: {
185
+ id: prepared.gate.id,
186
+ token: prepared.gate.token,
187
+ digest: prepared.gate.digest,
188
+ ...(prepared.gate.bound ? { bound: prepared.gate.bound } : {}),
189
+ },
190
+ createdAt: now.toISOString(),
191
+ };
192
+ const admissionRel = await writeAdmission(controlRepoRoot, admission);
193
+ const admissionPath = admissionRel.startsWith(".harness/")
194
+ ? admissionRel
195
+ : relativeSchedulerPath(controlRepoRoot, getAdmissionPath(controlRepoRoot, scheduleId)).startsWith("..")
196
+ ? `.harness/task-pool/scheduler/admissions/${scheduleId}.json`
197
+ : `.harness/task-pool/scheduler/admissions/${scheduleId}.json`;
198
+ const validated = await transitionSchedule({
199
+ controlRepoRoot,
200
+ scheduleId,
201
+ toStatus: "validated",
202
+ patch: {
203
+ admissionRef: admissionPath.startsWith(".")
204
+ ? admissionPath
205
+ : `.harness/task-pool/scheduler/admissions/${scheduleId}.json`,
206
+ isolation: {
207
+ mode: "git-worktree",
208
+ required: true,
209
+ baseBranch: base.baseBranch,
210
+ baseCommit: base.baseCommit,
211
+ worktreePath: workspace.relativeWorktreePath,
212
+ branch: workspace.relativeBranch,
213
+ mergePolicy: policy.mergePolicy,
214
+ mergeState: "pending-execution",
215
+ },
216
+ },
217
+ evidencePaths: [
218
+ `.harness/task-pool/scheduler/admissions/${scheduleId}.json`,
219
+ ],
220
+ now,
221
+ });
222
+ return {
223
+ schedule: validated.schedule,
224
+ admission,
225
+ reviewPacket: {
226
+ scheduleId,
227
+ featureId: taskSpec.feature_id,
228
+ taskId: taskSpec.id,
229
+ harnessTaskId: prepared.materializeManifest.harnessTaskId,
230
+ worktreePath: workspace.relativeWorktreePath,
231
+ branch: workspace.relativeBranch,
232
+ baseBranch: base.baseBranch,
233
+ baseCommit: base.baseCommit,
234
+ ...(prepared.writeSet ? { writeSet: prepared.writeSet } : {}),
235
+ gateToken: prepared.gate.token,
236
+ gateDigest: prepared.gate.digest,
237
+ ...(card.workItemRef ? { taskCard: card.workItemRef } : {}),
238
+ executeAtUtc: trigger.executeAtUtc,
239
+ admissionPath: `.harness/task-pool/scheduler/admissions/${scheduleId}.json`,
240
+ },
241
+ };
242
+ }
243
+ catch (error) {
244
+ // Best-effort reject so intent is not left as validating forever.
245
+ try {
246
+ const current = await readSchedule(controlRepoRoot, scheduleId);
247
+ if (current && current.status === "validating") {
248
+ await transitionSchedule({
249
+ controlRepoRoot,
250
+ scheduleId,
251
+ toStatus: "rejected",
252
+ reason: error instanceof Error ? error.message : String(error),
253
+ reasonCode: "admission-failed",
254
+ now: new Date(),
255
+ });
256
+ }
257
+ }
258
+ catch {
259
+ // ignore secondary failures
260
+ }
261
+ throw error;
262
+ }
263
+ }
264
+ /**
265
+ * Human-approved schedule commit: validated → scheduled + Task Pool Queued.
266
+ * Freezes gate approval receipt; does NOT call task advance --approve-gate.
267
+ */
268
+ export async function addSchedule(input) {
269
+ const now = input.now ?? new Date();
270
+ const controlRepoRoot = path.resolve(input.controlRepoRoot);
271
+ const schedule = await readSchedule(controlRepoRoot, input.scheduleId);
272
+ if (!schedule) {
273
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.NOT_FOUND, `schedule not found: ${input.scheduleId}`);
274
+ }
275
+ if (schedule.status !== "validated") {
276
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.INVALID_TRANSITION, `scheduler add requires validated status, found ${schedule.status}`);
277
+ }
278
+ const admission = await readAdmission(controlRepoRoot, input.scheduleId);
279
+ if (!admission) {
280
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.INVALID_RECORD, `admission missing for ${input.scheduleId}`);
281
+ }
282
+ if (!admission.gateApproval?.token || !admission.workspace) {
283
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.INVALID_RECORD, `admission incomplete for ${input.scheduleId} (gate/workspace required)`);
284
+ }
285
+ const expectedToken = admission.gateApproval.token;
286
+ const provided = input.approveGate.trim();
287
+ if (provided !== expectedToken) {
288
+ // Also accept bare digest if operator pasted digest only.
289
+ const expectedDigest = admission.gateApproval.digest;
290
+ const alt = `${admission.gateApproval.id}:${expectedDigest}`;
291
+ if (provided !== expectedDigest && provided !== alt) {
292
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.INVALID_RECORD, `approve-gate token mismatch for ${input.scheduleId}`);
293
+ }
294
+ }
295
+ if (!admission.workspace.baseBranch ||
296
+ !admission.workspace.baseCommit ||
297
+ !admission.workspace.path ||
298
+ !admission.workspace.branch ||
299
+ !admission.workspace.ownershipToken ||
300
+ !admission.harnessTaskId ||
301
+ !admission.dag?.sha256 ||
302
+ admission.dag.strictValidated !== true ||
303
+ !admission.gateApproval.id ||
304
+ !admission.gateApproval.digest) {
305
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.INVALID_RECORD, `admission missing required frozen fields for ${input.scheduleId}`);
306
+ }
307
+ const admissionPath = schedule.admissionRef ??
308
+ `.harness/task-pool/scheduler/admissions/${input.scheduleId}.json`;
309
+ await reserveTaskPoolForSchedule({
310
+ controlRepoRoot,
311
+ featureId: schedule.featureId,
312
+ taskId: schedule.taskId,
313
+ nightScheduleId: schedule.id,
314
+ admissionPath,
315
+ nightWorktreePath: admission.workspace.path,
316
+ nightBranch: admission.workspace.branch,
317
+ now,
318
+ });
319
+ const approvedAdmission = {
320
+ ...admission,
321
+ gateApproval: {
322
+ ...admission.gateApproval,
323
+ approvedAt: now.toISOString(),
324
+ approvedBy: "operator",
325
+ token: expectedToken,
326
+ },
327
+ };
328
+ await writeAdmission(controlRepoRoot, approvedAdmission);
329
+ const scheduled = await transitionSchedule({
330
+ controlRepoRoot,
331
+ scheduleId: input.scheduleId,
332
+ toStatus: "scheduled",
333
+ event: "schedule_created",
334
+ patch: {
335
+ reservation: {
336
+ taskPoolStatus: "Queued",
337
+ reservedAt: now.toISOString(),
338
+ },
339
+ admissionRef: admissionPath,
340
+ isolation: {
341
+ mode: "git-worktree",
342
+ required: true,
343
+ baseBranch: admission.workspace.baseBranch,
344
+ baseCommit: admission.workspace.baseCommit,
345
+ worktreePath: admission.workspace.path,
346
+ branch: admission.workspace.branch,
347
+ mergePolicy: schedule.isolation?.mergePolicy ??
348
+ schedule.policySnapshot?.mergePolicy ??
349
+ "manual-on-success",
350
+ mergeState: "pending-execution",
351
+ },
352
+ },
353
+ evidencePaths: [admissionPath],
354
+ now,
355
+ });
356
+ return {
357
+ schedule: scheduled.schedule,
358
+ admission: approvedAdmission,
359
+ taskPoolStatus: "Queued",
360
+ };
361
+ }
362
+ /**
363
+ * Cancel before dispatch and release Queued → Ready when bound.
364
+ */
365
+ export async function cancelScheduledReservation(input) {
366
+ const schedule = await readSchedule(input.controlRepoRoot, input.scheduleId);
367
+ if (!schedule) {
368
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.NOT_FOUND, `schedule not found: ${input.scheduleId}`);
369
+ }
370
+ const result = await cancelSchedule({
371
+ controlRepoRoot: input.controlRepoRoot,
372
+ scheduleId: input.scheduleId,
373
+ reason: input.reason,
374
+ now: input.now,
375
+ });
376
+ if (schedule.status === "scheduled" ||
377
+ schedule.status === "waiting" ||
378
+ schedule.status === "validated") {
379
+ await releaseTaskPoolReservation({
380
+ controlRepoRoot: input.controlRepoRoot,
381
+ featureId: schedule.featureId,
382
+ taskId: schedule.taskId,
383
+ nightScheduleId: schedule.id,
384
+ now: input.now,
385
+ });
386
+ }
387
+ return result.schedule;
388
+ }
389
+ async function resolveNightPolicy(controlRepoRoot, override) {
390
+ if (override) {
391
+ return nightSchedulerPolicySchema.parse(override);
392
+ }
393
+ try {
394
+ const manifest = await loadHarnessManifest(controlRepoRoot);
395
+ return nightSchedulerPolicySchema.parse(manifest.workerPolicy?.nightScheduler ?? {});
396
+ }
397
+ catch {
398
+ return nightSchedulerPolicySchema.parse({});
399
+ }
400
+ }
401
+ async function loadFeatureTask(featureDir, taskId) {
402
+ const tasksDir = path.join(featureDir, "tasks");
403
+ const taskGraphPath = path.join(tasksDir, "task-graph.yaml");
404
+ let graphRaw;
405
+ try {
406
+ graphRaw = await readFile(taskGraphPath, "utf-8");
407
+ }
408
+ catch (error) {
409
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.NOT_FOUND, `task graph is required for night admission: ${error instanceof Error ? error.message : String(error)}`);
410
+ }
411
+ const graph = YAML.parse(graphRaw);
412
+ const matchingNodes = (graph.nodes ?? []).filter((entry) => entry.id === taskId);
413
+ if (matchingNodes.length !== 1) {
414
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.INVALID_RECORD, `task graph must contain exactly one node for ${taskId}`);
415
+ }
416
+ const node = matchingNodes[0];
417
+ if (!node?.task) {
418
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.INVALID_RECORD, `task graph node ${taskId} must declare a task file`);
419
+ }
420
+ const taskRef = node.task;
421
+ const taskSpecPath = path.resolve(tasksDir, taskRef);
422
+ const taskSpecRelative = path.relative(path.resolve(tasksDir), taskSpecPath);
423
+ if (taskSpecRelative.length === 0 ||
424
+ taskSpecRelative.startsWith("..") ||
425
+ path.isAbsolute(taskSpecRelative)) {
426
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.INVALID_RECORD, `task graph node ${taskId} has unsafe task path: ${taskRef}`);
427
+ }
428
+ let raw;
429
+ try {
430
+ raw = await readFile(taskSpecPath, "utf-8");
431
+ }
432
+ catch (error) {
433
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.NOT_FOUND, `TaskSpec not found for ${taskId}: ${error instanceof Error ? error.message : String(error)}`);
434
+ }
435
+ const parsed = taskSpecSchema.parse(YAML.parse(raw));
436
+ if (parsed.id !== taskId) {
437
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.INVALID_RECORD, `TaskSpec id ${parsed.id} does not match requested taskId ${taskId}`);
438
+ }
439
+ if (graph.feature_id !== parsed.feature_id) {
440
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.INVALID_RECORD, `task graph feature_id ${graph.feature_id ?? "missing"} does not match TaskSpec ${parsed.feature_id}`);
441
+ }
442
+ const graphDependencies = node.depends_on ?? [];
443
+ if (JSON.stringify(graphDependencies) !== JSON.stringify(parsed.depends_on)) {
444
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.INVALID_RECORD, `task graph dependencies do not match TaskSpec for ${taskId}`);
445
+ }
446
+ return {
447
+ taskSpec: parsed,
448
+ taskSpecPath,
449
+ taskSpecSha: `sha256:${createHash("sha256").update(raw, "utf-8").digest("hex")}`,
450
+ taskGraphPath,
451
+ taskGraphSha: `sha256:${createHash("sha256").update(graphRaw, "utf-8").digest("hex")}`,
452
+ taskGraphDependencies: graphDependencies,
453
+ };
454
+ }
455
+ async function defaultPrepareInWorkspace(input) {
456
+ // Materialize + open writeSet gate inside the schedule worktree.
457
+ const materializeManifest = await materializeTaskSpec({
458
+ repoRoot: input.workspaceRoot,
459
+ taskSpec: input.taskSpec,
460
+ taskSpecPath: input.taskSpecPath,
461
+ client: input.client,
462
+ now: input.now,
463
+ });
464
+ const dagPath = path.join(input.workspaceRoot, ".harness", "tasks", materializeManifest.harnessTaskId, "artifacts", `admission-dag.json`);
465
+ const advanceResult = await input.client.run([
466
+ "task",
467
+ "advance",
468
+ materializeManifest.harnessTaskId,
469
+ "--profile",
470
+ materializeManifest.loopAgentProfile,
471
+ "--dag-output",
472
+ dagPath,
473
+ "--json",
474
+ ], {
475
+ cwd: input.workspaceRoot,
476
+ artifactName: `admission-task-advance-${materializeManifest.harnessTaskId}`,
477
+ });
478
+ const gate = extractGate(advanceResult, materializeManifest.harnessTaskId);
479
+ const dagBytes = await readFile(getTaskPaths(input.workspaceRoot, materializeManifest.harnessTaskId)
480
+ .dagDraftPath, "utf-8").catch(async () => readFile(dagPath, "utf-8"));
481
+ const dagSha256 = `sha256:${createHash("sha256").update(dagBytes, "utf-8").digest("hex")}`;
482
+ let nodeCount;
483
+ let writeSet;
484
+ try {
485
+ const dagJson = JSON.parse(dagBytes);
486
+ if (Array.isArray(dagJson.nodes))
487
+ nodeCount = dagJson.nodes.length;
488
+ if (Array.isArray(dagJson.writeSet))
489
+ writeSet = dagJson.writeSet;
490
+ }
491
+ catch {
492
+ // ignore
493
+ }
494
+ const identity = typeof input.client.getIdentity === "function"
495
+ ? input.client.getIdentity()
496
+ : undefined;
497
+ return {
498
+ materializeManifest,
499
+ dagPath,
500
+ dagSha256,
501
+ ...(nodeCount !== undefined ? { nodeCount } : {}),
502
+ ...(writeSet ? { writeSet } : {}),
503
+ gate,
504
+ ...(identity ? { controllerIdentity: identity } : {}),
505
+ };
506
+ }
507
+ function extractGate(result, taskId) {
508
+ const root = result.json;
509
+ if (!root || typeof root !== "object") {
510
+ throw new Error(`admission prepare did not return JSON for ${taskId}; cannot open writeSet gate`);
511
+ }
512
+ const envelope = root;
513
+ const payload = envelope.result && typeof envelope.result === "object"
514
+ ? envelope.result
515
+ : envelope;
516
+ const gate = payload.gate && typeof payload.gate === "object"
517
+ ? payload.gate
518
+ : undefined;
519
+ const id = typeof gate?.id === "string" ? gate.id : undefined;
520
+ const digest = typeof gate?.digest === "string" ? gate.digest : undefined;
521
+ if (!id || !digest) {
522
+ const lifecycleState = typeof payload.lifecycleState === "string"
523
+ ? payload.lifecycleState
524
+ : "unknown";
525
+ throw new Error(`admission prepare for ${taskId} did not open a writeSet gate (lifecycleState=${lifecycleState})`);
526
+ }
527
+ const bound = gate?.bound && typeof gate.bound === "object"
528
+ ? gate.bound
529
+ : undefined;
530
+ return {
531
+ id,
532
+ digest,
533
+ token: `${id}:${digest}`,
534
+ ...(bound ? { bound } : {}),
535
+ };
536
+ }
@@ -0,0 +1,99 @@
1
+ import path from "node:path";
2
+ import { draftFollowUpDecision, } from "../follow-up/factory.js";
3
+ /**
4
+ * ADR 0010: optional post-failure Follow-up draft enqueue.
5
+ * Default off via policySnapshot.autoFollowUpEnabled.
6
+ * Never approves; never mutates Ready / writeSet / admission.
7
+ */
8
+ export async function maybeEnqueueNightFollowUp(input) {
9
+ const enabled = input.schedule.policySnapshot?.autoFollowUpEnabled === true;
10
+ if (!enabled) {
11
+ return {
12
+ ok: false,
13
+ action: "skipped",
14
+ reason: "disabled",
15
+ detail: "policySnapshot.autoFollowUpEnabled is false (ADR 0010 default)",
16
+ };
17
+ }
18
+ const mode = input.schedule.policySnapshot?.autoFollowUpMode ?? "draft-only";
19
+ if (mode !== "draft-only") {
20
+ return {
21
+ ok: false,
22
+ action: "skipped",
23
+ reason: "unsupported-mode",
24
+ detail: `autoFollowUpMode=${mode} is not supported; only draft-only`,
25
+ };
26
+ }
27
+ if (input.schedule.status !== "failed") {
28
+ return {
29
+ ok: false,
30
+ action: "skipped",
31
+ reason: "not-failed",
32
+ detail: `schedule status is ${input.schedule.status}`,
33
+ };
34
+ }
35
+ const featureDirRel = input.schedule.featureDir?.trim();
36
+ if (!featureDirRel) {
37
+ return {
38
+ ok: false,
39
+ action: "skipped",
40
+ reason: "missing-feature-dir",
41
+ detail: "schedule.featureDir required to draft follow-up against Feature Packet",
42
+ };
43
+ }
44
+ const workerRunId = input.workerRunId?.trim();
45
+ if (!workerRunId) {
46
+ return {
47
+ ok: false,
48
+ action: "skipped",
49
+ reason: "missing-worker-run",
50
+ detail: "workerRunId required to bind failed run evidence",
51
+ };
52
+ }
53
+ if (path.isAbsolute(featureDirRel)) {
54
+ return {
55
+ ok: false,
56
+ action: "skipped",
57
+ reason: "unsafe-feature-dir",
58
+ detail: "schedule.featureDir must be repo-relative",
59
+ };
60
+ }
61
+ const featureDir = path.resolve(input.controlRepoRoot, featureDirRel);
62
+ const relativeFeatureDir = path.relative(path.resolve(input.controlRepoRoot), featureDir);
63
+ if (relativeFeatureDir.length === 0 ||
64
+ relativeFeatureDir.startsWith("..") ||
65
+ path.isAbsolute(relativeFeatureDir)) {
66
+ return {
67
+ ok: false,
68
+ action: "skipped",
69
+ reason: "unsafe-feature-dir",
70
+ detail: "schedule.featureDir escapes controlRepoRoot",
71
+ };
72
+ }
73
+ const draftFn = input.draft ?? draftFollowUpDecision;
74
+ try {
75
+ const result = await draftFn({
76
+ repoRoot: input.controlRepoRoot,
77
+ featureDir,
78
+ taskId: input.schedule.taskId,
79
+ workerRunId,
80
+ });
81
+ return {
82
+ ok: true,
83
+ action: result.created ? "drafted" : "reused",
84
+ kind: result.kind,
85
+ followUpId: result.followUpId,
86
+ draftPath: result.draftPath,
87
+ workerRunId,
88
+ featureDir,
89
+ };
90
+ }
91
+ catch (error) {
92
+ return {
93
+ ok: false,
94
+ action: "skipped",
95
+ reason: "draft-error",
96
+ detail: error instanceof Error ? error.message : String(error),
97
+ };
98
+ }
99
+ }