@amenophis1er/foreman 0.1.16 → 0.1.17

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/src/server.ts CHANGED
@@ -50,6 +50,14 @@
50
50
  * DELETE /chat?projectId= Forget the conversation and its session
51
51
  * PUT /providers/{id}/key Store a provider's key {key}
52
52
  * DELETE /providers/{id}/key Forget it
53
+ * GET /projects/{id}/schedules This project's schedules + this month's scheduled spend and its ceiling
54
+ * POST /projects/{id}/schedules Add one {name, brief, cadence, budgetUsd, …} → {schedule}
55
+ * PUT /schedules/{id} Change one (same fields, all optional) → {schedule}
56
+ * DELETE /schedules/{id} Forget one
57
+ * POST /schedules/{id}/pause Stop it firing until a human resumes it
58
+ * POST /schedules/{id}/resume Start it firing again, from now
59
+ * POST /schedules/{id}/run-now Start its mission at once (409 if the project is busy)
60
+ * GET /schedules/preview?cadence= The next three firings of a cadence {next:[ms]}
53
61
  */
54
62
  import http from 'node:http';
55
63
  import crypto from 'node:crypto';
@@ -73,14 +81,14 @@ import { budgetAnchor, modelRecords, projectRecord, recordLine } from './track-r
73
81
  import { reconcileRole } from './role-provider.js';
74
82
  import { detectBrowser, installChromium } from './browser.js';
75
83
  import { frozenDeck, frozenMissionDoc, parkMissionDoc, restoreMissionDoc, snapshotRun } from './snapshot.js';
76
- import { closeMissionBranch, compareUrl, createPullRequest, dirtyPaths, ensureMissionBranch, ghReady, gitInfo, resolvePrBase, missionBranchName, prDraft, pullRequestState, pushBranch, renameMissionBranch, startMissionBranch, type GitInfo } from './gitwork.js';
84
+ import { closeMissionBranch, compareUrl, createPullRequest, dirtyPaths, ensureMissionBranch, ghReady, gitInfo, resolvePrBase, worktreeGrant, worktreeParent, missionBranchName, prDraft, pullRequestState, pushBranch, renameMissionBranch, startMissionBranch, type GitInfo } from './gitwork.js';
77
85
  import { detectTailscale, tailnetUrl } from './tailscale.js';
78
86
  import { checkForUpdate, currentVersion, type UpdateInfo } from './update.js';
79
87
  import { ServiceRegistry, listeningPid, portOpen, servicesHandler, stopService } from './services.js';
80
88
  import { HELP_TEXT, expandHome, parseCommand, projectsRoot, slug } from './notify/commands.js';
81
89
  import {
82
90
  DEFAULT_FLEET_MODEL, FLEET_CHAT_ID, PHONE_CONTEXT_MS, phoneRoute, runFleetTurn,
83
- type FleetHost, type FleetProjectView,
91
+ type FleetHost, type FleetProjectView, type FleetScheduleView,
84
92
  } from './fleet-planner.js';
85
93
  import { escapeHtml as escTg } from './notify.js';
86
94
  import { RunStore, newRunId } from './store.js';
@@ -107,8 +115,12 @@ import {
107
115
  dirHasCredentials, detectAuth, hasKeychainCredentials, readAccount, type AuthMode,
108
116
  } from './preflight.js';
109
117
  import { combineBasis, costBasisOf } from './types.js';
118
+ import { describeCadence, nextRunAt, nextRuns, validateCadence, type Cadence } from './schedule.js';
119
+ import {
120
+ DEFAULT_SCHEDULED_MONTHLY_CAP_USD, afterRunOutcome, decideTicks, monthlyScheduledSpend,
121
+ } from './schedule-guards.js';
110
122
  import type {
111
- ChatMeta, CostBasis, ForemanEvent, MissionProposal, ModelChoice, Project, ProviderRef, RunMeta, ToolPolicy,
123
+ ChatMeta, CostBasis, ForemanEvent, MissionProposal, ModelChoice, Project, ProviderRef, RunMeta, Schedule, ToolPolicy,
112
124
  } from './types.js';
113
125
 
114
126
  /**
@@ -535,6 +547,24 @@ function activeRuns(): MissionRun[] {
535
547
  return [...activeByProject.values()].filter((r): r is MissionRun => Boolean(r));
536
548
  }
537
549
 
550
+ /**
551
+ * The metadata of every run this process is currently driving, by run id.
552
+ *
553
+ * The emitter has to answer "was this run started by a schedule?" at the
554
+ * instant it emits, and a run's `run_finished` can be emitted before the
555
+ * MissionRun object exists at all (a provider that will not resolve fails the
556
+ * run in driveRun's first few lines). Reading meta.json back there would be
557
+ * asynchronous, and the notification envelope has already gone out by then.
558
+ */
559
+ const drivingRuns = new Map<string, RunMeta>();
560
+
561
+ /**
562
+ * Schedule names by schedule id, so a notification can say which standing
563
+ * instruction started a run without a disk read on the emitter's path. Filled
564
+ * wherever schedules are loaded anyway — every ticker pass, and each dispatch.
565
+ */
566
+ const scheduleNames = new Map<string, string>();
567
+
538
568
  /**
539
569
  * What happened in the fleet lately, in one line each, for the front desk.
540
570
  *
@@ -610,6 +640,21 @@ function makeEmitter(runId: string, projectId: string) {
610
640
  });
611
641
  }
612
642
  }
643
+ // The end of a scheduled run is also the schedule's news: the phone should
644
+ // say nobody pressed start, and the schedule's own failure bookkeeping has
645
+ // to move on. This is where the server learns a run ended — the
646
+ // orchestrator knows nothing about schedules and should not.
647
+ if (event === 'run_finished') {
648
+ const meta = drivingRuns.get(runId) ?? activeRuns().find((r) => r.meta.id === runId)?.meta;
649
+ if (meta?.scheduleId) {
650
+ d.scheduled = true;
651
+ const name = scheduleNames.get(meta.scheduleId);
652
+ if (name) d.scheduleName = name;
653
+ void recordScheduleOutcome(meta.scheduleId, meta).catch((err) => {
654
+ console.error(`failed to record outcome of schedule ${meta.scheduleId}:`, err);
655
+ });
656
+ }
657
+ }
613
658
  if (!projectsCache.has(projectId)) {
614
659
  void store.getProject(projectId).then((p) => { if (p) projectsCache.set(projectId, { name: p.name }); });
615
660
  }
@@ -617,6 +662,24 @@ function makeEmitter(runId: string, projectId: string) {
617
662
  };
618
663
  }
619
664
 
665
+ /**
666
+ * An announcement about a schedule rather than about a run.
667
+ *
668
+ * A skipped or auto-paused schedule has no run to hang a line off — that is
669
+ * exactly what happened: nothing started. So the frame carries `runId: null`,
670
+ * goes nowhere near a run's event log, and otherwise travels the same three
671
+ * roads every other event does (open tabs, the front desk's news, the phone).
672
+ */
673
+ function scheduleNotice(projectId: string) {
674
+ return (event: string, data: unknown): void => {
675
+ const frame = `event: ${event}\ndata: ${JSON.stringify({ runId: null, projectId, data })}\n\n`;
676
+ for (const res of sseClients) res.write(frame);
677
+ const d = (data ?? {}) as Record<string, unknown>;
678
+ noteFleetEvent(projectId, event, d);
679
+ notifyHub.handle({ event, runId: null, projectId, data: d, ts: Date.now() });
680
+ };
681
+ }
682
+
620
683
  /**
621
684
  * Everything an agent needs to authenticate, including starting the provider's
622
685
  * gateway if it has one.
@@ -805,12 +868,24 @@ notifyHub.onAnswer((a) => {
805
868
  // brief, its budget, its models and its browser judgement.
806
869
  void startRun(a.projectId, project.folder, prop.mission, prop.budgetUsd,
807
870
  modelChoice(prop.directorModel), modelChoice(prop.workerModel), prop.browser === true,
808
- providerOf(project), { director: prop.directorProviderId, worker: prop.workerProviderId });
871
+ providerOf(project), { director: prop.directorProviderId, worker: prop.workerProviderId },
872
+ { startedBy: 'phone' });
809
873
  void notifyHub.say(`Started <b>${escTg(project.name)}</b> as proposed, cap $${prop.budgetUsd}.`);
810
874
  })();
811
875
  }
812
876
  });
813
877
 
878
+ /**
879
+ * A gap as a reader would say it: "in 15 h", "3 days ago". Used where a
880
+ * timestamp would make someone do arithmetic on their phone.
881
+ */
882
+ function relativeTime(ms: number): string {
883
+ const s = Math.round(ms / 1000);
884
+ const a = Math.abs(s);
885
+ const span = a < 90 ? 'a minute' : a < 5400 ? `${Math.round(a / 60)} min` : a < 172800 ? `${Math.round(a / 3600)} h` : `${Math.round(a / 86400)} days`;
886
+ return s >= 0 ? `in ${span}` : `${span} ago`;
887
+ }
888
+
814
889
  /** The project the phone last planned with; plain text continues it. */
815
890
  let lastPhonePlanning: string | null = null;
816
891
 
@@ -959,6 +1034,41 @@ const fleetHost: FleetHost = {
959
1034
  return out;
960
1035
  },
961
1036
 
1037
+ /**
1038
+ * The standing schedules, with what they have already cost this month
1039
+ * against their project's ceiling — the number that decides whether the next
1040
+ * unattended firing happens at all, and the one nobody can see from a phone.
1041
+ *
1042
+ * Reading only, like every other verb here. An unknown reference is an
1043
+ * error rather than an empty list: "no schedules" and "you named a project
1044
+ * that does not exist" are different answers, and a typo must not read as
1045
+ * reassurance.
1046
+ */
1047
+ async listSchedules(ref?: string): Promise<FleetScheduleView[]> {
1048
+ const project = ref ? await findProject(ref) : null;
1049
+ if (ref && !project) throw new Error(await noSuchProject(ref));
1050
+ const schedules = await store.listSchedules(project?.id);
1051
+ if (!schedules.length) return [];
1052
+ const runs = await store.listRuns().catch(() => [] as RunMeta[]);
1053
+ const now = new Date();
1054
+ const names = new Map((await store.listProjects()).map((p) => [p.id, p.name]));
1055
+ // One settings read and one spend count per project, not per schedule:
1056
+ // a project with six nightly schedules asks the same two questions six
1057
+ // times, and the answers cannot differ between them.
1058
+ const money = new Map<string, { monthSpendUsd: number; monthlyCapUsd: number }>();
1059
+ for (const projectId of new Set(schedules.map((s) => s.projectId))) {
1060
+ money.set(projectId, {
1061
+ monthSpendUsd: monthlyScheduledSpend(runs, projectId, now),
1062
+ monthlyCapUsd: (await effectiveSettings(projectId)).scheduledMonthlyCapUsd,
1063
+ });
1064
+ }
1065
+ return schedules.map((schedule) => ({
1066
+ projectName: names.get(schedule.projectId) ?? schedule.projectId,
1067
+ schedule,
1068
+ ...money.get(schedule.projectId)!,
1069
+ }));
1070
+ },
1071
+
962
1072
  async projectDetail(ref) {
963
1073
  const project = await findProject(ref);
964
1074
  if (!project) return noSuchProject(ref);
@@ -1246,9 +1356,35 @@ async function handlePhoneText(text: string, replyTo?: string): Promise<void> {
1246
1356
  if (!reserveProject(project.id)) return say(`<b>${escTg(project.name)}</b> already has an active mission.`);
1247
1357
  void startRun(project.id, project.folder, cmd.text, project.defaultBudgetUsd,
1248
1358
  modelChoice(undefined), modelChoice(undefined), /screenshot|browser|render|console/i.test(cmd.text),
1249
- providerOf(project));
1359
+ providerOf(project), {}, { startedBy: 'phone' });
1250
1360
  return say(`Started a mission on <b>${escTg(project.name)}</b> with a $${project.defaultBudgetUsd} cap. I will tell you when it needs you or ends.`);
1251
1361
  }
1362
+ case 'schedules': {
1363
+ // Reading only. A schedule is standing configuration — it decides
1364
+ // what a machine does while nobody is watching — and remote surfaces
1365
+ // never grant standing changes, so there is no create, edit, pause,
1366
+ // resume or run-now here. The reply says so rather than leaving
1367
+ // someone to discover it by trying.
1368
+ const project = cmd.project ? await findProject(cmd.project) : null;
1369
+ if (cmd.project && !project) return say(`No project called <b>${escTg(cmd.project)}</b>. /projects lists them.`);
1370
+ const schedules = await store.listSchedules(project?.id);
1371
+ const footer = 'Schedules are read-only from here — create, edit, pause and resume live in the dashboard.';
1372
+ if (!schedules.length) {
1373
+ return say(`No schedules${project ? ` on <b>${escTg(project.name)}</b>` : ''} yet.\n${footer}`);
1374
+ }
1375
+ const names = new Map((await store.listProjects()).map((p) => [p.id, p.name]));
1376
+ const lines = schedules.map((s) => {
1377
+ const state = s.pausedReason
1378
+ ? `paused (${s.pausedReason === 'monthly-cap' ? 'monthly cap' : s.pausedReason})`
1379
+ : s.enabled ? 'enabled' : 'disabled';
1380
+ const next = s.pausedReason || !s.enabled || s.nextRunAt === null
1381
+ ? 'no next run'
1382
+ : `next ${relativeTime(s.nextRunAt - Date.now())}`;
1383
+ const where = project ? '' : ` · ${escTg(names.get(s.projectId) ?? s.projectId)}`;
1384
+ return `• <b>${escTg(s.name)}</b>${where}\n ${escTg(describeCadence(s.cadence))} · ${next} · ${state}`;
1385
+ });
1386
+ return say(`<b>Schedules${project ? ` · ${escTg(project.name)}` : ''}</b>\n${lines.join('\n')}\n\n${footer}`);
1387
+ }
1252
1388
  case 'stop': {
1253
1389
  const project = cmd.project ? await findProject(cmd.project) : (lastPhonePlanning ? await store.getProject(lastPhonePlanning) : null);
1254
1390
  const abort = project && chatAborts.get(project.id);
@@ -1451,6 +1587,9 @@ async function driveRun(
1451
1587
  changes?: { directorChanged: boolean; workerChanged: boolean },
1452
1588
  ): Promise<void> {
1453
1589
  const emit = makeEmitter(meta.id, projectId);
1590
+ // Registered before anything can fail, so even a run that dies on its
1591
+ // provider still tells the emitter which schedule it belonged to.
1592
+ drivingRuns.set(meta.id, meta);
1454
1593
  // One resolution per run, from the provider frozen into the run's metadata.
1455
1594
  // A run that cannot resolve a credential must not start: dispatching anyway
1456
1595
  // would fall back to whatever the environment happens to hold.
@@ -1462,6 +1601,7 @@ async function driveRun(
1462
1601
  await store.writeMeta(meta).catch(() => {});
1463
1602
  emit('run_error', { error: `provider unavailable — ${problem}` });
1464
1603
  emit('run_finished', { status: 'error', costUsd: meta.costUsd });
1604
+ drivingRuns.delete(meta.id);
1465
1605
  activeByProject.delete(projectId);
1466
1606
  return;
1467
1607
  }
@@ -1543,6 +1683,7 @@ async function driveRun(
1543
1683
  await store.writeMeta(meta).catch(() => {});
1544
1684
  emit('run_error', { error: String(err instanceof Error ? err.message : err) });
1545
1685
  emit('run_finished', { status: 'error', costUsd: meta.costUsd });
1686
+ drivingRuns.delete(meta.id);
1546
1687
  activeByProject.delete(projectId);
1547
1688
  return;
1548
1689
  }
@@ -1613,6 +1754,7 @@ async function driveRun(
1613
1754
  } finally {
1614
1755
  // However the run ended, it no longer needs its gateways.
1615
1756
  releaseGateways(meta.id);
1757
+ drivingRuns.delete(meta.id);
1616
1758
  if (activeByProject.get(projectId) === run) activeByProject.delete(projectId);
1617
1759
  // Freeze the record: the mission doc and the deck as they stand at this
1618
1760
  // moment, beside the run's meta and log. Done first, before the branch
@@ -1651,8 +1793,12 @@ async function effectiveSettings(projectId: string): Promise<{
1651
1793
  directorProviderId?: string; workerProviderId?: string;
1652
1794
  /** In a repository, each mission runs on a branch of its own (default on). */
1653
1795
  gitBranchPerMission: boolean;
1796
+ /** A mission in a worktree may use its parent repository without asking (default on). */
1797
+ allowWorktreeParent: boolean;
1654
1798
  /** Percent of the cap at which the director is told to start verifying (default 80). */
1655
1799
  budgetWarnAt: number;
1800
+ /** Ceiling on what this project's SCHEDULED runs may cost in one calendar month. */
1801
+ scheduledMonthlyCapUsd: number;
1656
1802
  }> {
1657
1803
  const s = await store.readSettings()
1658
1804
  .catch(() => ({ global: {}, projects: {} as Record<string, object> }));
@@ -1674,10 +1820,17 @@ async function effectiveSettings(projectId: string): Promise<{
1674
1820
  directorProviderId: str(p.directorProviderId ?? g.directorProviderId),
1675
1821
  workerProviderId: str(p.workerProviderId ?? g.workerProviderId),
1676
1822
  gitBranchPerMission: (p.gitBranchPerMission ?? g.gitBranchPerMission) !== false,
1823
+ allowWorktreeParent: (p.allowWorktreeParent ?? g.allowWorktreeParent) !== false,
1677
1824
  budgetWarnAt: (() => {
1678
1825
  const raw = Number(p.budgetWarnAt ?? g.budgetWarnAt);
1679
1826
  return Number.isFinite(raw) && raw > 0 && raw < 100 ? raw : 60;
1680
1827
  })(),
1828
+ scheduledMonthlyCapUsd: (() => {
1829
+ // Zero is a legal answer — "this project may not spend unattended at
1830
+ // all" — so only a negative or unreadable value falls back to the default.
1831
+ const raw = Number(p.scheduledMonthlyCapUsd ?? g.scheduledMonthlyCapUsd);
1832
+ return Number.isFinite(raw) && raw >= 0 ? raw : DEFAULT_SCHEDULED_MONTHLY_CAP_USD;
1833
+ })(),
1681
1834
  };
1682
1835
  }
1683
1836
 
@@ -1697,17 +1850,77 @@ async function gitInfoCached(folder: string): Promise<GitInfo> {
1697
1850
  return value;
1698
1851
  }
1699
1852
 
1853
+ /**
1854
+ * A mission in a git worktree gets its parent repository without being asked.
1855
+ *
1856
+ * The worktree holds the branch's files; the build config, the shared type
1857
+ * declarations and the parent's node_modules live up in the repository it was
1858
+ * made from. A crew working in a worktree therefore crosses the boundary on
1859
+ * almost every command, and answering the same question all day is not
1860
+ * oversight — it is noise that trains the human to click Allow without
1861
+ * reading. The parent is opened at the start instead, once, in the open: the
1862
+ * transcript records it the same way it records a human's own grant.
1863
+ *
1864
+ * Only the parent. Sibling worktrees stay closed, because one of them may be
1865
+ * another mission's workspace, and so does a parent that a live run is
1866
+ * already working in.
1867
+ */
1868
+ async function grantWorktreeParent(
1869
+ meta: RunMeta, allowed: boolean, emit: ReturnType<typeof makeEmitter>,
1870
+ ): Promise<void> {
1871
+ // Re-derived from scratch every start and every resume, never merely added
1872
+ // to: the setting may have been turned off since, or another mission may
1873
+ // have taken the parent, and a grant that outlives its reason is a hole.
1874
+ // Only Foreman's own grants are withdrawn; a human's stay.
1875
+ const previous = meta.autoRoots ?? [];
1876
+ const shape = allowed ? await worktreeParent(meta.folder).catch(() => null) : null;
1877
+ const busy = activeRuns().filter((r) => r.meta.id !== meta.id).map((r) => r.meta.folder);
1878
+ const decision = worktreeGrant(shape, busy);
1879
+ const now = decision.grant ? [decision.grant] : [];
1880
+ const withdrawn = previous.filter((p) => !now.includes(p));
1881
+
1882
+ if (withdrawn.length || now.some((p) => !previous.includes(p))) {
1883
+ meta.allowedRoots = [...(meta.allowedRoots ?? []).filter((p) => !previous.includes(p)), ...now];
1884
+ meta.autoRoots = now;
1885
+ await store.writeMeta(meta).catch(() => {});
1886
+ }
1887
+ for (const p of withdrawn) {
1888
+ emit('git_note', { text: `Closed ${p} again: ${decision.reason ?? 'Foreman no longer opens it for this mission'}. The crew will ask before it steps outside the mission folder.` });
1889
+ }
1890
+ if (decision.grant && !previous.includes(decision.grant)) {
1891
+ emit('root_allowed', {
1892
+ path: decision.grant, agent: 'foreman',
1893
+ reason: 'this mission runs in a worktree of that repository',
1894
+ });
1895
+ } else if (!decision.grant && decision.reason && !withdrawn.length) {
1896
+ emit('git_note', { text: `Left closed: ${decision.reason}. The crew will ask before it steps outside the mission folder.` });
1897
+ }
1898
+ }
1899
+
1700
1900
  async function startRun(
1701
1901
  projectId: string, folder: string, mission: string, budgetUsd: number,
1702
1902
  directorModel: ModelChoice, workerModel: ModelChoice, browserTools: boolean,
1703
1903
  provider: ProviderRef,
1704
1904
  roleProviders: { director?: string; worker?: string } = {},
1905
+ /**
1906
+ * How this run began. Recorded rather than inferred: an unattended run and
1907
+ * one somebody is watching deserve different treatment later, and the
1908
+ * schedule's monthly ceiling can only count what says it was scheduled.
1909
+ */
1910
+ origin: {
1911
+ startedBy?: 'human' | 'phone' | 'schedule' | 'mcp';
1912
+ scheduleId?: string;
1913
+ scheduleName?: string;
1914
+ } = {},
1705
1915
  ): Promise<void> {
1706
1916
  const settings = await effectiveSettings(projectId);
1917
+ if (origin.scheduleId && origin.scheduleName) scheduleNames.set(origin.scheduleId, origin.scheduleName);
1707
1918
  const meta: RunMeta = {
1708
1919
  id: newRunId(),
1709
1920
  projectId,
1710
1921
  folder, mission, budgetUsd,
1922
+ startedBy: origin.startedBy ?? 'human',
1923
+ ...(origin.scheduleId ? { scheduleId: origin.scheduleId } : {}),
1711
1924
  // An explicit composer choice wins; "Default" inherits from Settings.
1712
1925
  directorModel: directorModel ?? settings.directorModel,
1713
1926
  workerModel: workerModel ?? settings.workerModel,
@@ -1734,6 +1947,23 @@ async function startRun(
1734
1947
  console.error(`failed to create run for project ${projectId}:`, err);
1735
1948
  return;
1736
1949
  }
1950
+ // Whoever reads this transcript later did not start this run, and the first
1951
+ // question they will have is who did. It is the opening line, before the
1952
+ // branch note, so the answer is at the top rather than buried in the meta.
1953
+ if (origin.scheduleId) {
1954
+ const emit = makeEmitter(meta.id, projectId);
1955
+ emit('run_note', {
1956
+ text: `Started by schedule ${origin.scheduleName ?? scheduleNames.get(origin.scheduleId) ?? origin.scheduleId}.`,
1957
+ });
1958
+ }
1959
+ // The schedule's "last run" is this one, recorded the moment it exists
1960
+ // rather than when it ends: a schedule whose mission is still running should
1961
+ // point at it, not at the one before.
1962
+ if (origin.scheduleId) {
1963
+ await store.updateSchedule(origin.scheduleId, {
1964
+ lastRunId: meta.id, lastRunAt: meta.createdAt, lastNote: '',
1965
+ }).catch(() => {});
1966
+ }
1737
1967
  await consumeProposal(projectId, meta.id, mission).catch(() => {});
1738
1968
  // The folder's MISSION.md belongs to whichever run wrote it. Parked into
1739
1969
  // that run's record (when it lacks one) and cleared, so this run's status
@@ -1767,6 +1997,7 @@ async function startRun(
1767
1997
  }
1768
1998
  }
1769
1999
  }
2000
+ await grantWorktreeParent(meta, settings.allowWorktreeParent, makeEmitter(meta.id, projectId));
1770
2001
  await driveRun(projectId, meta);
1771
2002
  }
1772
2003
 
@@ -1827,6 +2058,7 @@ async function resumeRun(projectId: string, meta: RunMeta, pick: {
1827
2058
  if (back) makeEmitter(meta.id, projectId)('git_note', { text: `Could not return to ${meta.git.branch} (${back}); the resumed mission runs on whatever is checked out.` });
1828
2059
  gitInfoCache.delete(meta.folder);
1829
2060
  }
2061
+ await grantWorktreeParent(meta, (await effectiveSettings(projectId)).allowWorktreeParent, makeEmitter(meta.id, projectId));
1830
2062
  // The director resumes from MISSION.md. If another mission ran here since,
1831
2063
  // the folder's copy is that mission's; this run's own goes back first.
1832
2064
  const restored = await restoreMissionDoc(store.runDirectory(meta.id), meta.folder).catch(() => 'none' as const);
@@ -1845,6 +2077,153 @@ async function resumeRun(projectId: string, meta: RunMeta, pick: {
1845
2077
  });
1846
2078
  }
1847
2079
 
2080
+ // ---------------------------------------------------------------------------
2081
+ // Schedules
2082
+ // ---------------------------------------------------------------------------
2083
+
2084
+ /**
2085
+ * Starts a schedule's mission. The project must already be reserved.
2086
+ *
2087
+ * The schedule's own choices win; anything it leaves open falls back to the
2088
+ * project's effective settings, which is what startRun does with an absent
2089
+ * model anyway. The browser judgement is the same heuristic the phone's /run
2090
+ * uses, and for the same reason: there is no box for anyone to tick.
2091
+ */
2092
+ function startScheduledRun(s: Schedule, project: Project, startedBy: 'human' | 'schedule'): void {
2093
+ void startRun(
2094
+ s.projectId, project.folder, s.brief, s.budgetUsd,
2095
+ modelChoice(s.directorModel), modelChoice(s.workerModel),
2096
+ /screenshot|browser|render|console/i.test(s.brief),
2097
+ providerOf(project),
2098
+ { director: s.directorProviderId, worker: s.workerProviderId },
2099
+ { startedBy, scheduleId: s.id, scheduleName: s.name },
2100
+ );
2101
+ }
2102
+
2103
+ /** The next firing of this cadence as a timestamp, or null when there is none. */
2104
+ function nextRunAtMs(cadence: Cadence, after: Date): number | null {
2105
+ const next = nextRunAt(cadence, after);
2106
+ return next ? next.getTime() : null;
2107
+ }
2108
+
2109
+ /**
2110
+ * A scheduled run ended: the schedule remembers how it went.
2111
+ *
2112
+ * Two failures in a row pause it — see afterRunOutcome. Called from the
2113
+ * emitter, which is where the server learns a run finished; the orchestrator
2114
+ * knows nothing about schedules and should not have to.
2115
+ */
2116
+ async function recordScheduleOutcome(scheduleId: string, meta: RunMeta): Promise<void> {
2117
+ const s = await store.getSchedule(scheduleId);
2118
+ if (!s) return;
2119
+ const outcome = afterRunOutcome(s, { status: meta.status, stopReason: meta.stopReason });
2120
+ await store.updateSchedule(s.id, {
2121
+ lastRunId: meta.id,
2122
+ lastRunAt: meta.endedAt ?? Date.now(),
2123
+ lastOutcome: meta.status === 'running' ? undefined : meta.status,
2124
+ // Whatever the last tick could not do, it did this time: the note would
2125
+ // otherwise still read "project busy" beside a run that just finished.
2126
+ lastNote: '',
2127
+ consecutiveFailures: outcome.consecutiveFailures,
2128
+ pausedReason: outcome.pausedReason,
2129
+ });
2130
+ if (outcome.pausedNow) {
2131
+ const emit = scheduleNotice(s.projectId);
2132
+ emit('schedule_paused', { scheduleId: s.id, name: s.name, projectId: s.projectId, reason: 'failures' });
2133
+ }
2134
+ }
2135
+
2136
+ /**
2137
+ * One pass of the ticker: what is due, and what to do about it.
2138
+ *
2139
+ * Passes must not overlap. A pass awaits the store several times, and two of
2140
+ * them interleaving could both see the same schedule as due and start it
2141
+ * twice — the project reservation would catch that, but as a 409 nobody is
2142
+ * there to read rather than as a rule.
2143
+ */
2144
+ let tickInFlight = false;
2145
+ async function scheduleTick(): Promise<void> {
2146
+ if (tickInFlight) return;
2147
+ tickInFlight = true;
2148
+ try {
2149
+ const schedules = await store.listSchedules();
2150
+ if (!schedules.length) return;
2151
+ for (const s of schedules) scheduleNames.set(s.id, s.name);
2152
+ const now = new Date();
2153
+ // A schedule with no next firing would sit dormant for ever — a record
2154
+ // written by an older build, or one whose cadence was never scheduled.
2155
+ // Giving it one here costs a write once and never again.
2156
+ for (const s of schedules) {
2157
+ if (!s.enabled || s.pausedReason !== null || s.nextRunAt !== null) continue;
2158
+ s.nextRunAt = nextRunAtMs(s.cadence, now);
2159
+ await store.updateSchedule(s.id, { nextRunAt: s.nextRunAt }).catch(() => {});
2160
+ }
2161
+ const runs = await store.listRuns().catch(() => [] as RunMeta[]);
2162
+ const monthSpend = new Map<string, number>();
2163
+ const caps = new Map<string, number>();
2164
+ for (const projectId of new Set(schedules.map((s) => s.projectId))) {
2165
+ monthSpend.set(projectId, monthlyScheduledSpend(runs, projectId, now));
2166
+ caps.set(projectId, (await effectiveSettings(projectId)).scheduledMonthlyCapUsd);
2167
+ }
2168
+ const actions = decideTicks({
2169
+ schedules,
2170
+ now,
2171
+ busyProjectIds: new Set(activeByProject.keys()),
2172
+ monthSpend,
2173
+ capFor: (projectId) => caps.get(projectId) ?? DEFAULT_SCHEDULED_MONTHLY_CAP_USD,
2174
+ });
2175
+ for (const action of actions) {
2176
+ const s = schedules.find((x) => x.id === action.scheduleId);
2177
+ if (!s) continue;
2178
+ if (action.kind === 'pause') {
2179
+ await store.updateSchedule(s.id, { pausedReason: action.reason }).catch(() => {});
2180
+ const emit = scheduleNotice(s.projectId);
2181
+ emit('schedule_paused', {
2182
+ scheduleId: s.id, name: s.name, projectId: s.projectId, reason: action.reason,
2183
+ });
2184
+ continue;
2185
+ }
2186
+ const skipped = async (nextAt: number | null) => {
2187
+ await store.updateSchedule(s.id, {
2188
+ nextRunAt: nextAt, lastOutcome: 'skipped', lastNote: 'project busy',
2189
+ }).catch(() => {});
2190
+ const emit = scheduleNotice(s.projectId);
2191
+ emit('schedule_skipped', {
2192
+ scheduleId: s.id, name: s.name, projectId: s.projectId, reason: 'project busy',
2193
+ });
2194
+ };
2195
+ if (action.kind === 'skip') { await skipped(action.nextRunAt); continue; }
2196
+ const project = await store.getProject(s.projectId).catch(() => null);
2197
+ if (!project) {
2198
+ // The project was unlinked and this schedule outlived it. Nothing to
2199
+ // announce; move it along so the tick does not repeat every 30s.
2200
+ await store.updateSchedule(s.id, { nextRunAt: action.nextRunAt }).catch(() => {});
2201
+ continue;
2202
+ }
2203
+ // Reservation is the last step before dispatch — no awaits in between,
2204
+ // exactly as in POST /run. It can still fail: another dispatch may have
2205
+ // taken the project since this pass read the active map, and that is the
2206
+ // skip case, not a tick to drop on the floor.
2207
+ if (!reserveProject(s.projectId)) { await skipped(action.nextRunAt); continue; }
2208
+ startScheduledRun(s, project, 'schedule');
2209
+ await store.updateSchedule(s.id, { nextRunAt: action.nextRunAt }).catch(() => {});
2210
+ }
2211
+ } catch (err) {
2212
+ // A ticker that throws is a ticker that stops. Nothing here is worth the
2213
+ // schedules of every other project.
2214
+ console.error('schedule tick failed:', err);
2215
+ } finally {
2216
+ tickInFlight = false;
2217
+ }
2218
+ }
2219
+
2220
+ // Every half minute, and once shortly after startup so a firing missed while
2221
+ // the machine was off is caught up rather than waiting for the next slot. The
2222
+ // first pass is delayed: the orphan sweep and the store's own startup work
2223
+ // come first, and a schedule is never so urgent that ten seconds matter.
2224
+ setTimeout(() => void scheduleTick(), 10_000).unref();
2225
+ setInterval(() => void scheduleTick(), 30_000).unref();
2226
+
1848
2227
  // ---------------------------------------------------------------------------
1849
2228
  // Static files
1850
2229
  // ---------------------------------------------------------------------------
@@ -1928,6 +2307,52 @@ async function browseRoots(): Promise<string[]> {
1928
2307
  return roots;
1929
2308
  }
1930
2309
 
2310
+ /**
2311
+ * A schedule's writable fields out of a request body, or the first thing wrong
2312
+ * with them. `partial` is the PUT: an absent field means "leave it alone",
2313
+ * where on the POST it means the schedule would be missing something it needs.
2314
+ *
2315
+ * The cadence goes through validateCadence rather than being trusted, because
2316
+ * the failure this guards against is silent — a cron expression nobody can
2317
+ * parse becomes a schedule that simply never fires, and looks healthy doing it.
2318
+ */
2319
+ function parseScheduleBody(
2320
+ b: Record<string, unknown>, partial: boolean,
2321
+ ): { error: string } | { fields: Partial<Schedule> } {
2322
+ const fields: Partial<Schedule> = {};
2323
+ for (const key of ['name', 'brief'] as const) {
2324
+ if (b[key] === undefined) {
2325
+ if (!partial) return { error: `${key} is required` };
2326
+ continue;
2327
+ }
2328
+ const v = typeof b[key] === 'string' ? (b[key] as string).trim() : '';
2329
+ if (!v) return { error: `${key} must not be empty` };
2330
+ fields[key] = v;
2331
+ }
2332
+ if (b.budgetUsd !== undefined) {
2333
+ const n = Number(b.budgetUsd);
2334
+ if (!Number.isFinite(n) || n <= 0) return { error: 'budgetUsd must be a positive number' };
2335
+ fields.budgetUsd = n;
2336
+ } else if (!partial) {
2337
+ return { error: 'budgetUsd is required' };
2338
+ }
2339
+ if (b.cadence !== undefined) {
2340
+ const parsed = validateCadence(b.cadence);
2341
+ if (!parsed.ok) return { error: parsed.error };
2342
+ fields.cadence = parsed.cadence;
2343
+ } else if (!partial) {
2344
+ return { error: 'cadence is required' };
2345
+ }
2346
+ for (const key of ['directorModel', 'workerModel'] as const) {
2347
+ if (typeof b[key] === 'string' && (b[key] as string).trim()) fields[key] = modelChoice(b[key]);
2348
+ }
2349
+ for (const key of ['directorProviderId', 'workerProviderId'] as const) {
2350
+ if (typeof b[key] === 'string' && (b[key] as string).trim()) fields[key] = (b[key] as string).trim();
2351
+ }
2352
+ if (typeof b.enabled === 'boolean') fields.enabled = b.enabled;
2353
+ return { fields };
2354
+ }
2355
+
1931
2356
  function json(res: http.ServerResponse, code: number, body: unknown): void {
1932
2357
  res.writeHead(code, { 'content-type': 'application/json' });
1933
2358
  res.end(JSON.stringify(body));
@@ -1969,6 +2394,9 @@ const server = http.createServer(async (req, res) => {
1969
2394
  const stopServiceMatch = url.pathname.match(/^\/runs\/([^/]+)\/services\/(\d{1,5})\/stop$/);
1970
2395
  const projectMatch = url.pathname.match(/^\/projects\/([^/]+)$/);
1971
2396
  const providerKeyMatch = url.pathname.match(/^\/providers\/([A-Za-z0-9_-]{1,64})\/key$/);
2397
+ const projectSchedulesMatch = url.pathname.match(/^\/projects\/([^/]+)\/schedules$/);
2398
+ const scheduleMatch = url.pathname.match(/^\/schedules\/([^/]+)$/);
2399
+ const scheduleActionMatch = url.pathname.match(/^\/schedules\/([^/]+)\/(pause|resume|run-now)$/);
1972
2400
 
1973
2401
  try {
1974
2402
  if (req.method === 'GET' && (url.pathname === '/'
@@ -2252,7 +2680,7 @@ const server = http.createServer(async (req, res) => {
2252
2680
  } else if (req.method === 'POST' && url.pathname === '/run') {
2253
2681
  const {
2254
2682
  projectId, mission, budgetUsd, directorModel, workerModel, browserTools,
2255
- directorProviderId, workerProviderId, allowDirty,
2683
+ directorProviderId, workerProviderId, allowDirty, startedBy,
2256
2684
  } = await readBody(req);
2257
2685
  if (typeof projectId !== 'string' || typeof mission !== 'string' || !mission.trim()) {
2258
2686
  return json(res, 400, { error: 'projectId and mission are required' });
@@ -2294,7 +2722,11 @@ const server = http.createServer(async (req, res) => {
2294
2722
  {
2295
2723
  director: typeof directorProviderId === 'string' ? directorProviderId : undefined,
2296
2724
  worker: typeof workerProviderId === 'string' ? workerProviderId : undefined,
2297
- });
2725
+ },
2726
+ // 'schedule' is deliberately not accepted here: a caller must not be
2727
+ // able to forge a scheduled start and charge the month's unattended
2728
+ // allowance for a run no schedule asked for.
2729
+ { startedBy: startedBy === 'phone' || startedBy === 'mcp' ? startedBy : 'human' });
2298
2730
  json(res, 200, { ok: true });
2299
2731
 
2300
2732
  } else if (url.pathname === '/notify' && req.method === 'GET') {
@@ -2901,6 +3333,107 @@ const server = http.createServer(async (req, res) => {
2901
3333
  if (!project) return json(res, 404, { error: 'unknown project' });
2902
3334
  json(res, 200, await readMemory(project.folder));
2903
3335
 
3336
+ } else if (req.method === 'GET' && url.pathname === '/schedules/preview') {
3337
+ // The picker's "next three runs". Deliberately the same function the
3338
+ // ticker fires from, so what the human is shown and what will actually
3339
+ // happen cannot drift apart.
3340
+ let cadence: unknown;
3341
+ try { cadence = JSON.parse(url.searchParams.get('cadence') ?? ''); }
3342
+ catch { return json(res, 400, { error: 'cadence must be a JSON object in the query string' }); }
3343
+ const parsed = validateCadence(cadence);
3344
+ if (!parsed.ok) return json(res, 400, { error: parsed.error });
3345
+ json(res, 200, { next: nextRuns(parsed.cadence, new Date(), 3).map((d) => d.getTime()) });
3346
+
3347
+ } else if (req.method === 'GET' && projectSchedulesMatch) {
3348
+ const projectId = projectSchedulesMatch[1];
3349
+ if (!await store.getProject(projectId)) return json(res, 404, { error: 'unknown project' });
3350
+ const [schedules, runs, settings] = await Promise.all([
3351
+ store.listSchedules(projectId), store.listRuns().catch(() => [] as RunMeta[]), effectiveSettings(projectId),
3352
+ ]);
3353
+ json(res, 200, {
3354
+ schedules,
3355
+ monthSpendUsd: monthlyScheduledSpend(runs, projectId, new Date()),
3356
+ monthlyCapUsd: settings.scheduledMonthlyCapUsd,
3357
+ });
3358
+
3359
+ } else if (req.method === 'POST' && projectSchedulesMatch) {
3360
+ const projectId = projectSchedulesMatch[1];
3361
+ if (!await store.getProject(projectId)) return json(res, 404, { error: 'unknown project' });
3362
+ const parsed = parseScheduleBody(await readBody(req), false);
3363
+ if ('error' in parsed) return json(res, 400, { error: parsed.error });
3364
+ const f = parsed.fields;
3365
+ const schedule = await store.addSchedule({
3366
+ projectId,
3367
+ name: f.name!, brief: f.brief!, cadence: f.cadence!, budgetUsd: f.budgetUsd!,
3368
+ directorModel: f.directorModel, workerModel: f.workerModel,
3369
+ directorProviderId: f.directorProviderId, workerProviderId: f.workerProviderId,
3370
+ // Enabled unless the caller said otherwise: a schedule nobody switched
3371
+ // on is a form somebody filled in and forgot.
3372
+ enabled: f.enabled !== false,
3373
+ nextRunAt: f.enabled === false ? null : nextRunAtMs(f.cadence!, new Date()),
3374
+ consecutiveFailures: 0,
3375
+ pausedReason: null,
3376
+ });
3377
+ scheduleNames.set(schedule.id, schedule.name);
3378
+ json(res, 200, { schedule });
3379
+
3380
+ } else if (req.method === 'PUT' && scheduleMatch) {
3381
+ const existing = await store.getSchedule(scheduleMatch[1]);
3382
+ if (!existing) return json(res, 404, { error: 'unknown schedule' });
3383
+ const parsed = parseScheduleBody(await readBody(req), true);
3384
+ if ('error' in parsed) return json(res, 400, { error: parsed.error });
3385
+ const f = parsed.fields;
3386
+ const enabled = f.enabled ?? existing.enabled;
3387
+ const patch: Partial<Schedule> = { ...f };
3388
+ // A changed cadence is a changed answer to "when next?", counted from
3389
+ // now: keeping the old firing time would mean the schedule the human
3390
+ // just moved fires one more time on the schedule they moved it off.
3391
+ if (f.cadence || f.enabled !== undefined) {
3392
+ patch.nextRunAt = enabled && existing.pausedReason === null
3393
+ ? nextRunAtMs(f.cadence ?? existing.cadence, new Date())
3394
+ : null;
3395
+ }
3396
+ const schedule = await store.updateSchedule(existing.id, patch);
3397
+ if (schedule) scheduleNames.set(schedule.id, schedule.name);
3398
+ json(res, 200, { schedule });
3399
+
3400
+ } else if (req.method === 'DELETE' && scheduleMatch) {
3401
+ const removed = await store.removeSchedule(scheduleMatch[1]);
3402
+ json(res, removed ? 200 : 404, removed ? { ok: true } : { error: 'unknown schedule' });
3403
+
3404
+ } else if (req.method === 'POST' && scheduleActionMatch) {
3405
+ const [, scheduleId, action] = scheduleActionMatch;
3406
+ const s = await store.getSchedule(scheduleId);
3407
+ if (!s) return json(res, 404, { error: 'unknown schedule' });
3408
+ if (action === 'pause') {
3409
+ json(res, 200, { schedule: await store.updateSchedule(s.id, { pausedReason: 'human', nextRunAt: null }) });
3410
+
3411
+ } else if (action === 'resume') {
3412
+ // Resuming is a dashboard act and has no remote equivalent on purpose:
3413
+ // whatever paused this — the human, the month's ceiling, two failures
3414
+ // in a row — is a thing to look at before it runs unattended again.
3415
+ json(res, 200, {
3416
+ schedule: await store.updateSchedule(s.id, {
3417
+ pausedReason: null,
3418
+ consecutiveFailures: 0,
3419
+ nextRunAt: s.enabled ? nextRunAtMs(s.cadence, new Date()) : null,
3420
+ }),
3421
+ });
3422
+
3423
+ } else {
3424
+ const project = await store.getProject(s.projectId);
3425
+ if (!project) return json(res, 404, { error: 'unknown project' });
3426
+ // Allowed even at the monthly ceiling, and not counted against it
3427
+ // beforehand: the ceiling governs UNATTENDED spending, and a human
3428
+ // pressing a button is by definition not that. The run still carries
3429
+ // the scheduleId, so what it costs does count towards the month.
3430
+ if (!reserveProject(s.projectId)) {
3431
+ return json(res, 409, { error: 'this project already has an active mission' });
3432
+ }
3433
+ startScheduledRun(s, project, 'human');
3434
+ json(res, 200, { ok: true });
3435
+ }
3436
+
2904
3437
  } else if (req.method === 'GET' && url.pathname === '/missiondoc') {
2905
3438
  const runId = url.searchParams.get('run');
2906
3439
  if (!runId) return json(res, 400, { error: 'run parameter is required' });