harnery 0.15.0 → 0.17.0

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 (48) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/supervisor.d.ts.map +1 -1
  3. package/dist/commands/supervisor.js +393 -4
  4. package/dist/core/supervisor/index.d.ts +4 -0
  5. package/dist/core/supervisor/index.d.ts.map +1 -1
  6. package/dist/core/supervisor/index.js +4 -0
  7. package/dist/core/supervisor/plan-api.d.ts +16 -0
  8. package/dist/core/supervisor/plan-api.d.ts.map +1 -0
  9. package/dist/core/supervisor/plan-api.js +34 -0
  10. package/dist/core/supervisor/plan-read.d.ts +4 -0
  11. package/dist/core/supervisor/plan-read.d.ts.map +1 -0
  12. package/dist/core/supervisor/plan-read.js +269 -0
  13. package/dist/core/supervisor/plan-types.d.ts +113 -0
  14. package/dist/core/supervisor/plan-types.d.ts.map +1 -0
  15. package/dist/core/supervisor/plan-types.js +14 -0
  16. package/dist/core/supervisor/planning.d.ts +26 -0
  17. package/dist/core/supervisor/planning.d.ts.map +1 -0
  18. package/dist/core/supervisor/planning.js +486 -0
  19. package/dist/core/supervisor/read.d.ts +4 -0
  20. package/dist/core/supervisor/read.d.ts.map +1 -1
  21. package/dist/core/supervisor/read.js +3 -0
  22. package/dist/core/supervisor/runner.d.ts +3 -0
  23. package/dist/core/supervisor/runner.d.ts.map +1 -1
  24. package/dist/core/supervisor/runner.js +37 -7
  25. package/dist/core/supervisor/service-read.d.ts +6 -0
  26. package/dist/core/supervisor/service-read.d.ts.map +1 -0
  27. package/dist/core/supervisor/service-read.js +127 -0
  28. package/dist/core/supervisor/service.d.ts +114 -0
  29. package/dist/core/supervisor/service.d.ts.map +1 -0
  30. package/dist/core/supervisor/service.js +706 -0
  31. package/dist/core/supervisor/state.d.ts +11 -1
  32. package/dist/core/supervisor/state.d.ts.map +1 -1
  33. package/dist/core/supervisor/state.js +186 -7
  34. package/dist/lib/tunnel/gate.d.ts.map +1 -1
  35. package/dist/lib/tunnel/gate.js +6 -1
  36. package/package.json +1 -1
  37. package/src/commands/supervisor.ts +490 -3
  38. package/src/core/supervisor/index.ts +45 -0
  39. package/src/core/supervisor/plan-api.ts +47 -0
  40. package/src/core/supervisor/plan-read.ts +299 -0
  41. package/src/core/supervisor/plan-types.ts +144 -0
  42. package/src/core/supervisor/planning.ts +576 -0
  43. package/src/core/supervisor/read.ts +30 -0
  44. package/src/core/supervisor/runner.ts +39 -2
  45. package/src/core/supervisor/service-read.ts +144 -0
  46. package/src/core/supervisor/service.ts +927 -0
  47. package/src/core/supervisor/state.ts +235 -6
  48. package/src/lib/tunnel/gate.ts +7 -1
@@ -12,16 +12,27 @@ import {
12
12
  writeFileSync,
13
13
  } from "node:fs";
14
14
  import { hostname } from "node:os";
15
- import { dirname, join, resolve } from "node:path";
15
+ import { dirname, isAbsolute, join, resolve } from "node:path";
16
16
  import { assertWorkId, readWorkItem, type WorkRecord } from "../work/read.ts";
17
+ import { workflowScriptDigest } from "../workflow/run-state.ts";
17
18
  import { normalizeWorkflowSpecialists } from "../workflow/specialists.ts";
18
19
  import type { WorkflowSpecialistProfile } from "../workflow/types.ts";
20
+ import { readSupervisorPlans } from "./plan-read.ts";
21
+ import {
22
+ type CreateSupervisorReplanningInput,
23
+ type SupervisorPlanHistory,
24
+ type SupervisorPlanRecord,
25
+ type SupervisorReplanningPolicy,
26
+ supervisorGraphFingerprint,
27
+ } from "./plan-types.ts";
19
28
 
20
29
  export const SUPERVISOR_INTENT_SCHEMA_VERSION = 1 as const;
21
30
 
22
31
  const GOAL_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
23
32
  const MAX_TITLE = 200;
24
33
  const MAX_INTENT_BYTES = 256 * 1024;
34
+ const MAX_TEMPLATES = 20;
35
+ const TEMPLATE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
25
36
  const FOREIGN_LEASE_STALE_MS = 24 * 60 * 60 * 1_000;
26
37
 
27
38
  export interface SupervisorLimits {
@@ -47,6 +58,7 @@ export interface SupervisorIntent {
47
58
  specialists: Record<string, WorkflowSpecialistProfile>;
48
59
  limits: SupervisorLimits;
49
60
  automation: SupervisorAutomationPolicy;
61
+ replanning?: SupervisorReplanningPolicy;
50
62
  created_at: string;
51
63
  }
52
64
 
@@ -64,6 +76,8 @@ export type SupervisorNextAction =
64
76
  | "resolve_approval"
65
77
  | "review"
66
78
  | "retry"
79
+ | "replan"
80
+ | "review_plan"
67
81
  | "none";
68
82
 
69
83
  export interface SupervisorProjection {
@@ -81,12 +95,19 @@ export interface SupervisorProjection {
81
95
  attempts_used: number;
82
96
  attempts_remaining: number;
83
97
  specialists: string[];
98
+ plan_generation: number;
99
+ replans_used: number;
100
+ replans_remaining: number;
101
+ pending_plan_id?: string;
102
+ latest_plan_status?: SupervisorPlanRecord["status"];
103
+ governed_work_ids: string[];
84
104
  }
85
105
 
86
106
  export interface SupervisorRecord {
87
107
  intent: SupervisorIntent;
88
108
  projection: SupervisorProjection;
89
109
  work: WorkRecord[];
110
+ plans: SupervisorPlanRecord[];
90
111
  }
91
112
 
92
113
  export interface CreateSupervisorInput {
@@ -97,6 +118,7 @@ export interface CreateSupervisorInput {
97
118
  id?: string;
98
119
  limits?: Partial<SupervisorLimits>;
99
120
  automation?: Partial<SupervisorAutomationPolicy>;
121
+ replanning?: CreateSupervisorReplanningInput;
100
122
  }
101
123
 
102
124
  export function createSupervisor(input: CreateSupervisorInput): SupervisorRecord {
@@ -105,6 +127,9 @@ export function createSupervisor(input: CreateSupervisorInput): SupervisorRecord
105
127
  const root = readWorkItem(coordRoot, input.rootWorkId);
106
128
  const id = input.id ?? newSupervisorId();
107
129
  assertSupervisorId(id);
130
+ const replanning = input.replanning
131
+ ? normalizeReplanning(input.replanning, input.specialists)
132
+ : undefined;
108
133
  const intent: SupervisorIntent = {
109
134
  schema_version: SUPERVISOR_INTENT_SCHEMA_VERSION,
110
135
  id,
@@ -113,6 +138,7 @@ export function createSupervisor(input: CreateSupervisorInput): SupervisorRecord
113
138
  specialists: normalizeWorkflowSpecialists(input.specialists),
114
139
  limits: normalizeLimits(input.limits),
115
140
  automation: normalizeAutomation(input.automation),
141
+ ...(replanning ? { replanning } : {}),
116
142
  created_at: new Date().toISOString(),
117
143
  };
118
144
  const path = supervisorIntentPath(coordRoot, id);
@@ -221,11 +247,14 @@ function readSupervisorInternal(
221
247
  ): SupervisorRecord {
222
248
  const coordRoot = resolve(coordRootRaw);
223
249
  const intent = readSupervisorIntent(coordRoot, goalId);
224
- const work = collectSupervisorWork(coordRoot, intent.root_work_id);
250
+ const plans = readSupervisorPlans(coordRoot, intent.id, intent.root_work_id);
251
+ const work = collectSupervisorWork(coordRoot, plans.active_root_work_id);
252
+ const governed = collectGovernedWork(coordRoot, intent.root_work_id, plans, work);
225
253
  return {
226
254
  intent,
227
255
  work,
228
- projection: deriveProjection(coordRoot, intent, work, ignoreLease),
256
+ plans: plans.plans,
257
+ projection: deriveProjection(coordRoot, intent, work, governed, plans, ignoreLease),
229
258
  };
230
259
  }
231
260
 
@@ -233,11 +262,13 @@ function deriveProjection(
233
262
  coordRoot: string,
234
263
  intent: SupervisorIntent,
235
264
  work: WorkRecord[],
265
+ governed: WorkRecord[],
266
+ plans: SupervisorPlanHistory,
236
267
  ignoreLease: boolean,
237
268
  ): SupervisorProjection {
238
- const root = work.find((record) => record.intent.id === intent.root_work_id);
269
+ const root = work.find((record) => record.intent.id === plans.active_root_work_id);
239
270
  if (!root) throw new Error(`supervisor ${intent.id} root work is missing`);
240
- const attemptsUsed = work.reduce((sum, record) => sum + record.projection.attempts_used, 0);
271
+ const attemptsUsed = governed.reduce((sum, record) => sum + record.projection.attempts_used, 0);
241
272
  const readyWork = work
242
273
  .filter((record) => record.projection.state === "ready")
243
274
  .map((record) => record.intent.id);
@@ -283,7 +314,7 @@ function deriveProjection(
283
314
  const base = {
284
315
  id: intent.id,
285
316
  title: intent.title,
286
- root_work_id: intent.root_work_id,
317
+ root_work_id: plans.active_root_work_id,
287
318
  work_ids: work.map((record) => record.intent.id),
288
319
  ready_work: readyWork,
289
320
  resumable_work: resumableWork,
@@ -292,6 +323,12 @@ function deriveProjection(
292
323
  attempts_used: attemptsUsed,
293
324
  attempts_remaining: Math.max(0, intent.limits.max_total_attempts - attemptsUsed),
294
325
  specialists: Object.keys(intent.specialists),
326
+ plan_generation: plans.generation,
327
+ replans_used: plans.plans.length,
328
+ replans_remaining: Math.max(0, (intent.replanning?.max_replans ?? 0) - plans.plans.length),
329
+ pending_plan_id: plans.latest?.status === "proposed" ? plans.latest.request.id : undefined,
330
+ latest_plan_status: plans.latest?.status,
331
+ governed_work_ids: governed.map((record) => record.intent.id),
295
332
  };
296
333
  if (!ignoreLease && supervisorLeaseIsLive(coordRoot, intent.id)) {
297
334
  return {
@@ -309,6 +346,30 @@ function deriveProjection(
309
346
  next_action: "none",
310
347
  };
311
348
  }
349
+ if (plans.latest?.status === "proposed") {
350
+ return {
351
+ ...base,
352
+ state: "awaiting_attention",
353
+ reason: `replacement plan ${plans.latest.request.id} awaits explicit review`,
354
+ next_action: "review_plan",
355
+ };
356
+ }
357
+ if (plans.latest?.status === "awaiting_approval") {
358
+ return {
359
+ ...base,
360
+ state: "awaiting_attention",
361
+ reason: `planner workflow awaits approval ${plans.latest.approval_id}`,
362
+ next_action: "resolve_approval",
363
+ };
364
+ }
365
+ if (plans.latest?.status === "resumable") {
366
+ return {
367
+ ...base,
368
+ state: "ready",
369
+ reason: `planner workflow ${plans.latest.request.workflow_run_id} is resumable`,
370
+ next_action: "replan",
371
+ };
372
+ }
312
373
  const resumableDispatchable = intent.automation.resume_approved ? resumableWork : [];
313
374
  const attemptDispatchable = [
314
375
  ...readyWork,
@@ -379,6 +440,58 @@ function deriveProjection(
379
440
  next_action: "retry",
380
441
  };
381
442
  }
443
+ if (attemptsUsed >= intent.limits.max_total_attempts && cancelled.length === 0) {
444
+ return {
445
+ ...base,
446
+ state: "budget_exhausted",
447
+ reason: `goal exhausted its ${intent.limits.max_total_attempts} total attempts`,
448
+ next_action: "none",
449
+ };
450
+ }
451
+ const triggerFingerprint = supervisorGraphFingerprint({
452
+ rootWorkId: plans.active_root_work_id,
453
+ generation: plans.generation,
454
+ work,
455
+ });
456
+ const latestHandledSameGraph =
457
+ plans.latest?.request.trigger_fingerprint === triggerFingerprint &&
458
+ plans.latest.request.prior_root_work_id === plans.active_root_work_id &&
459
+ plans.latest.status === "attention";
460
+ const canReplan =
461
+ intent.replanning !== undefined &&
462
+ plans.plans.length < intent.replanning.max_replans &&
463
+ cancelled.length === 0 &&
464
+ !latestHandledSameGraph;
465
+ if (canReplan) {
466
+ return {
467
+ ...base,
468
+ state: "ready",
469
+ reason: terminalBlocked.length
470
+ ? `${terminalBlocked.length} terminally blocked work item${terminalBlocked.length === 1 ? " can" : "s can"} be replanned`
471
+ : "the active graph has no legal progress action and may be replanned",
472
+ next_action: "replan",
473
+ };
474
+ }
475
+ if (
476
+ intent.replanning &&
477
+ plans.plans.length >= intent.replanning.max_replans &&
478
+ cancelled.length === 0
479
+ ) {
480
+ return {
481
+ ...base,
482
+ state: "budget_exhausted",
483
+ reason: `goal exhausted its ${intent.replanning.max_replans} replans`,
484
+ next_action: "none",
485
+ };
486
+ }
487
+ if (latestHandledSameGraph && plans.latest) {
488
+ return {
489
+ ...base,
490
+ state: "awaiting_attention",
491
+ reason: plans.latest.reason ?? `plan ${plans.latest.request.id} requires attention`,
492
+ next_action: "none",
493
+ };
494
+ }
382
495
  return {
383
496
  ...base,
384
497
  state: "blocked",
@@ -430,6 +543,7 @@ function validateIntent(intent: SupervisorIntent, goalId: string): void {
430
543
  ) {
431
544
  throw new Error(`supervisor intent ${goalId} automation policy is not canonical`);
432
545
  }
546
+ if (intent.replanning) validateReplanning(intent.replanning, specialists, goalId);
433
547
  }
434
548
 
435
549
  function normalizeLimits(input: Partial<SupervisorLimits> | undefined): SupervisorLimits {
@@ -465,6 +579,121 @@ function normalizeAutomation(
465
579
  };
466
580
  }
467
581
 
582
+ function normalizeReplanning(
583
+ input: CreateSupervisorReplanningInput,
584
+ specialistsInput: Readonly<Record<string, WorkflowSpecialistProfile>>,
585
+ ): SupervisorReplanningPolicy {
586
+ const specialists = normalizeWorkflowSpecialists(specialistsInput);
587
+ const plannerSpecialist = bounded(input.plannerSpecialist, "planner specialist", 100);
588
+ if (!specialists[plannerSpecialist]) {
589
+ throw new Error(`planner specialist ${plannerSpecialist} is not present in the frozen team`);
590
+ }
591
+ const entries = Object.entries(input.templates);
592
+ if (entries.length < 1 || entries.length > MAX_TEMPLATES) {
593
+ throw new Error(`replanning templates must contain 1 to ${MAX_TEMPLATES} entries`);
594
+ }
595
+ const templates = Object.fromEntries(
596
+ entries
597
+ .sort(([left], [right]) => left.localeCompare(right))
598
+ .map(([id, template]) => {
599
+ if (!TEMPLATE_ID.test(id)) throw new Error(`invalid replanning template id ${id}`);
600
+ const path = resolve(template.workflowPath);
601
+ if (!existsSync(path))
602
+ throw new Error(`replanning template ${id} does not exist at ${path}`);
603
+ const maxAttempts = positive(
604
+ template.maxAttempts ?? 3,
605
+ `replanning template ${id} maxAttempts`,
606
+ 100,
607
+ );
608
+ return [
609
+ id,
610
+ {
611
+ workflow: { path, sha256: workflowScriptDigest(path) },
612
+ max_attempts: maxAttempts,
613
+ root: boolean(template.root ?? false, `replanning template ${id} root`),
614
+ },
615
+ ];
616
+ }),
617
+ );
618
+ if (!Object.values(templates).some((template) => template.root)) {
619
+ throw new Error("replanning requires at least one root-capable template");
620
+ }
621
+ const maxWorkItemsPerPlan = positive(
622
+ input.maxWorkItemsPerPlan ?? 8,
623
+ "replanning max_work_items_per_plan",
624
+ 25,
625
+ );
626
+ const maxTotalWorkItems = positive(
627
+ input.maxTotalWorkItems ?? 25,
628
+ "replanning max_total_work_items",
629
+ 100,
630
+ );
631
+ if (maxWorkItemsPerPlan > maxTotalWorkItems) {
632
+ throw new Error("replanning max_work_items_per_plan cannot exceed max_total_work_items");
633
+ }
634
+ return {
635
+ planner_specialist: plannerSpecialist,
636
+ auto_apply: boolean(input.autoApply ?? false, "replanning auto_apply"),
637
+ max_replans: positive(input.maxReplans ?? 5, "replanning max_replans", 100),
638
+ max_work_items_per_plan: maxWorkItemsPerPlan,
639
+ max_total_work_items: maxTotalWorkItems,
640
+ templates,
641
+ };
642
+ }
643
+
644
+ function validateReplanning(
645
+ policy: SupervisorReplanningPolicy,
646
+ specialists: Record<string, WorkflowSpecialistProfile>,
647
+ goalId: string,
648
+ ): void {
649
+ if (!specialists[policy.planner_specialist]) {
650
+ throw new Error(`supervisor ${goalId} planner specialist is not in the frozen team`);
651
+ }
652
+ boolean(policy.auto_apply, "replanning auto_apply");
653
+ positive(policy.max_replans, "replanning max_replans", 100);
654
+ positive(policy.max_work_items_per_plan, "replanning max_work_items_per_plan", 25);
655
+ positive(policy.max_total_work_items, "replanning max_total_work_items", 100);
656
+ if (policy.max_work_items_per_plan > policy.max_total_work_items) {
657
+ throw new Error(`supervisor ${goalId} replanning work-item bounds are inconsistent`);
658
+ }
659
+ const entries = Object.entries(policy.templates ?? {});
660
+ if (entries.length < 1 || entries.length > MAX_TEMPLATES) {
661
+ throw new Error(`supervisor ${goalId} has invalid replanning templates`);
662
+ }
663
+ let rootCapable = false;
664
+ for (const [id, template] of entries) {
665
+ if (
666
+ !TEMPLATE_ID.test(id) ||
667
+ !isAbsolute(template.workflow?.path) ||
668
+ !/^[a-f0-9]{64}$/.test(template.workflow?.sha256) ||
669
+ !Number.isSafeInteger(template.max_attempts) ||
670
+ template.max_attempts < 1 ||
671
+ template.max_attempts > 100 ||
672
+ typeof template.root !== "boolean"
673
+ ) {
674
+ throw new Error(`supervisor ${goalId} replanning template ${id} is invalid`);
675
+ }
676
+ rootCapable ||= template.root;
677
+ }
678
+ if (!rootCapable) throw new Error(`supervisor ${goalId} has no root-capable replanning template`);
679
+ }
680
+
681
+ function collectGovernedWork(
682
+ coordRoot: string,
683
+ originalRootWorkId: string,
684
+ plans: SupervisorPlanHistory,
685
+ active: WorkRecord[],
686
+ ): WorkRecord[] {
687
+ const governed = new Map<string, WorkRecord>();
688
+ for (const record of collectSupervisorWork(coordRoot, originalRootWorkId)) {
689
+ governed.set(record.intent.id, record);
690
+ }
691
+ for (const workId of plans.applied_work_ids)
692
+ governed.set(workId, readWorkItem(coordRoot, workId));
693
+ for (const record of active) governed.set(record.intent.id, record);
694
+ return [...governed.values()];
695
+ }
696
+
468
697
  function supervisorDir(coordRoot: string, goalId: string): string {
469
698
  assertSupervisorId(goalId);
470
699
  return join(resolve(coordRoot), ".harnery", "supervisors", goalId);
@@ -47,13 +47,19 @@ const server = Bun.serve<WsData, never>({
47
47
  // Bun's max, and beyond any realistic single-turn delay.
48
48
  idleTimeout: 255,
49
49
  async fetch(req, server) {
50
+ const url = new URL(req.url);
50
51
  if (ACCESS === "cloudflare-allowlist") {
51
52
  const ip = req.headers.get("cf-connecting-ip") ?? "";
52
53
  if (!ALLOW.has(ip)) {
54
+ // Log denials so operators can whitelist a phone/laptop that just
55
+ // hit 403 without asking the human to dig up their public IP.
56
+ console.log(
57
+ // lint-ok-emission: detached worker, see file note above
58
+ `deny: ${ip || "(missing-cf-connecting-ip)"} ${req.method} ${url.pathname}`,
59
+ );
53
60
  return new Response("403 Forbidden\n", { status: 403 });
54
61
  }
55
62
  }
56
- const url = new URL(req.url);
57
63
 
58
64
  if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
59
65
  if (server.upgrade(req, { data: { path: url.pathname + url.search } })) {