@amenophis1er/foreman 0.1.16 → 0.1.18
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/README.md +74 -3
- package/package.json +1 -1
- package/src/crew.test.ts +376 -0
- package/src/crew.ts +330 -0
- package/src/fleet-planner.test.ts +39 -1
- package/src/fleet-planner.ts +83 -3
- package/src/gitwork.test.ts +133 -2
- package/src/gitwork.ts +211 -2
- package/src/mcp.test.ts +38 -1
- package/src/mcp.ts +86 -5
- package/src/notify/commands.test.ts +2 -0
- package/src/notify/commands.ts +6 -0
- package/src/notify/telegram.ts +1 -0
- package/src/notify.test.ts +61 -0
- package/src/notify.ts +45 -1
- package/src/orchestrator.test.ts +516 -2
- package/src/orchestrator.ts +514 -69
- package/src/run-crew.test.ts +99 -0
- package/src/run-crew.ts +101 -0
- package/src/schedule-guards.test.ts +236 -0
- package/src/schedule-guards.ts +149 -0
- package/src/schedule.test.ts +240 -0
- package/src/schedule.ts +343 -0
- package/src/server.ts +674 -11
- package/src/store.test.ts +81 -1
- package/src/store.ts +117 -3
- package/src/types.ts +92 -0
- package/ui/dist/assets/index-0QuGXbFg.js +76 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-DOVnExqF.js +0 -68
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,16 @@ 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';
|
|
85
|
+
import { crewPresetsFrom, type CrewPreset } from './crew.js';
|
|
86
|
+
import { frozenCrewFor, reviewReportLines, reviewedByNames } from './run-crew.js';
|
|
77
87
|
import { detectTailscale, tailnetUrl } from './tailscale.js';
|
|
78
88
|
import { checkForUpdate, currentVersion, type UpdateInfo } from './update.js';
|
|
79
89
|
import { ServiceRegistry, listeningPid, portOpen, servicesHandler, stopService } from './services.js';
|
|
80
90
|
import { HELP_TEXT, expandHome, parseCommand, projectsRoot, slug } from './notify/commands.js';
|
|
81
91
|
import {
|
|
82
92
|
DEFAULT_FLEET_MODEL, FLEET_CHAT_ID, PHONE_CONTEXT_MS, phoneRoute, runFleetTurn,
|
|
83
|
-
type FleetHost, type FleetProjectView,
|
|
93
|
+
type FleetHost, type FleetProjectView, type FleetScheduleView,
|
|
84
94
|
} from './fleet-planner.js';
|
|
85
95
|
import { escapeHtml as escTg } from './notify.js';
|
|
86
96
|
import { RunStore, newRunId } from './store.js';
|
|
@@ -107,8 +117,12 @@ import {
|
|
|
107
117
|
dirHasCredentials, detectAuth, hasKeychainCredentials, readAccount, type AuthMode,
|
|
108
118
|
} from './preflight.js';
|
|
109
119
|
import { combineBasis, costBasisOf } from './types.js';
|
|
120
|
+
import { describeCadence, nextRunAt, nextRuns, validateCadence, type Cadence } from './schedule.js';
|
|
121
|
+
import {
|
|
122
|
+
DEFAULT_SCHEDULED_MONTHLY_CAP_USD, afterRunOutcome, decideTicks, monthlyScheduledSpend,
|
|
123
|
+
} from './schedule-guards.js';
|
|
110
124
|
import type {
|
|
111
|
-
ChatMeta, CostBasis, ForemanEvent, MissionProposal, ModelChoice, Project, ProviderRef, RunMeta, ToolPolicy,
|
|
125
|
+
ChatMeta, CostBasis, ForemanEvent, MissionProposal, ModelChoice, Project, ProviderRef, RunMeta, Schedule, ToolPolicy,
|
|
112
126
|
} from './types.js';
|
|
113
127
|
|
|
114
128
|
/**
|
|
@@ -535,6 +549,24 @@ function activeRuns(): MissionRun[] {
|
|
|
535
549
|
return [...activeByProject.values()].filter((r): r is MissionRun => Boolean(r));
|
|
536
550
|
}
|
|
537
551
|
|
|
552
|
+
/**
|
|
553
|
+
* The metadata of every run this process is currently driving, by run id.
|
|
554
|
+
*
|
|
555
|
+
* The emitter has to answer "was this run started by a schedule?" at the
|
|
556
|
+
* instant it emits, and a run's `run_finished` can be emitted before the
|
|
557
|
+
* MissionRun object exists at all (a provider that will not resolve fails the
|
|
558
|
+
* run in driveRun's first few lines). Reading meta.json back there would be
|
|
559
|
+
* asynchronous, and the notification envelope has already gone out by then.
|
|
560
|
+
*/
|
|
561
|
+
const drivingRuns = new Map<string, RunMeta>();
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Schedule names by schedule id, so a notification can say which standing
|
|
565
|
+
* instruction started a run without a disk read on the emitter's path. Filled
|
|
566
|
+
* wherever schedules are loaded anyway — every ticker pass, and each dispatch.
|
|
567
|
+
*/
|
|
568
|
+
const scheduleNames = new Map<string, string>();
|
|
569
|
+
|
|
538
570
|
/**
|
|
539
571
|
* What happened in the fleet lately, in one line each, for the front desk.
|
|
540
572
|
*
|
|
@@ -610,6 +642,21 @@ function makeEmitter(runId: string, projectId: string) {
|
|
|
610
642
|
});
|
|
611
643
|
}
|
|
612
644
|
}
|
|
645
|
+
// The end of a scheduled run is also the schedule's news: the phone should
|
|
646
|
+
// say nobody pressed start, and the schedule's own failure bookkeeping has
|
|
647
|
+
// to move on. This is where the server learns a run ended — the
|
|
648
|
+
// orchestrator knows nothing about schedules and should not.
|
|
649
|
+
if (event === 'run_finished') {
|
|
650
|
+
const meta = drivingRuns.get(runId) ?? activeRuns().find((r) => r.meta.id === runId)?.meta;
|
|
651
|
+
if (meta?.scheduleId) {
|
|
652
|
+
d.scheduled = true;
|
|
653
|
+
const name = scheduleNames.get(meta.scheduleId);
|
|
654
|
+
if (name) d.scheduleName = name;
|
|
655
|
+
void recordScheduleOutcome(meta.scheduleId, meta).catch((err) => {
|
|
656
|
+
console.error(`failed to record outcome of schedule ${meta.scheduleId}:`, err);
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
}
|
|
613
660
|
if (!projectsCache.has(projectId)) {
|
|
614
661
|
void store.getProject(projectId).then((p) => { if (p) projectsCache.set(projectId, { name: p.name }); });
|
|
615
662
|
}
|
|
@@ -617,6 +664,24 @@ function makeEmitter(runId: string, projectId: string) {
|
|
|
617
664
|
};
|
|
618
665
|
}
|
|
619
666
|
|
|
667
|
+
/**
|
|
668
|
+
* An announcement about a schedule rather than about a run.
|
|
669
|
+
*
|
|
670
|
+
* A skipped or auto-paused schedule has no run to hang a line off — that is
|
|
671
|
+
* exactly what happened: nothing started. So the frame carries `runId: null`,
|
|
672
|
+
* goes nowhere near a run's event log, and otherwise travels the same three
|
|
673
|
+
* roads every other event does (open tabs, the front desk's news, the phone).
|
|
674
|
+
*/
|
|
675
|
+
function scheduleNotice(projectId: string) {
|
|
676
|
+
return (event: string, data: unknown): void => {
|
|
677
|
+
const frame = `event: ${event}\ndata: ${JSON.stringify({ runId: null, projectId, data })}\n\n`;
|
|
678
|
+
for (const res of sseClients) res.write(frame);
|
|
679
|
+
const d = (data ?? {}) as Record<string, unknown>;
|
|
680
|
+
noteFleetEvent(projectId, event, d);
|
|
681
|
+
notifyHub.handle({ event, runId: null, projectId, data: d, ts: Date.now() });
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
620
685
|
/**
|
|
621
686
|
* Everything an agent needs to authenticate, including starting the provider's
|
|
622
687
|
* gateway if it has one.
|
|
@@ -639,6 +704,101 @@ async function agentEnvFor(
|
|
|
639
704
|
return providerEnv(resolved, ledgerKey ? `${url}/run/${encodeURIComponent(ledgerKey)}` : url);
|
|
640
705
|
}
|
|
641
706
|
|
|
707
|
+
/**
|
|
708
|
+
* An agent env per crew preset that named a provider of its own, keyed by
|
|
709
|
+
* preset id — the third kind of env a run can need, after its two roles. A
|
|
710
|
+
* reviewer pinned to another provider is a real request: the point of a second
|
|
711
|
+
* opinion is partly that it comes from a different model, and a model carries
|
|
712
|
+
* its provider.
|
|
713
|
+
*
|
|
714
|
+
* Only presets that name a `providerId` get an entry, and only when that
|
|
715
|
+
* provider resolves cleanly. Anything else — no id, an id nothing resolves,
|
|
716
|
+
* a resolution `providerProblem` calls unusable — is LEFT OUT rather than
|
|
717
|
+
* reported, because a missing entry means the reviewer runs on the worker env
|
|
718
|
+
* and a thrown error means the run dies. A provider deleted between the moment
|
|
719
|
+
* a human chose the crew and the moment the director asks for the review must
|
|
720
|
+
* degrade to the worker's provider: the alternative is a mission stranded at
|
|
721
|
+
* the one step that would let it be recorded as done, over a credential its
|
|
722
|
+
* reviewer never strictly needed.
|
|
723
|
+
*
|
|
724
|
+
* Each preset's env carries the preset's own model for the same reason the two
|
|
725
|
+
* roles do — the SDK's aliases have to resolve to something this provider's
|
|
726
|
+
* gateway serves — and shares the run's ledger key, since the reviewer's
|
|
727
|
+
* tokens are accounted to the worker role. See the `env` comment in
|
|
728
|
+
* orchestrator.ts's requestReviewTool for why that approximation is chosen.
|
|
729
|
+
*/
|
|
730
|
+
/**
|
|
731
|
+
* Each crew preset's own cost basis, by preset id — for the presets that name
|
|
732
|
+
* a provider Foreman can resolve, which are the ones that will run somewhere
|
|
733
|
+
* other than the worker role.
|
|
734
|
+
*
|
|
735
|
+
* The run needs this to count a review's spend honestly. A reviewer pinned to
|
|
736
|
+
* a paid provider beside workers on a free gateway had its dollars discarded,
|
|
737
|
+
* because cost was attributed to the worker role and a gateway role's figures
|
|
738
|
+
* are dropped by design. Whether money is real is the provider's answer, not
|
|
739
|
+
* the role's.
|
|
740
|
+
*/
|
|
741
|
+
async function crewBasesFor(meta: RunMeta): Promise<Record<string, { basis: CostBasis; native: boolean }> | undefined> {
|
|
742
|
+
const wanted = (meta.crew ?? []).filter((p) => p.providerId);
|
|
743
|
+
if (!wanted.length) return undefined;
|
|
744
|
+
const out: Record<string, { basis: CostBasis; native: boolean }> = {};
|
|
745
|
+
const seen = new Map<string, { basis: CostBasis; native: boolean } | null>();
|
|
746
|
+
for (const preset of wanted) {
|
|
747
|
+
const id = preset.providerId as string;
|
|
748
|
+
if (!seen.has(id)) {
|
|
749
|
+
const ref = providerForRole(meta, id);
|
|
750
|
+
const resolved = 'id' in ref && ref.id === id
|
|
751
|
+
? await resolveProvider(ref, store.root).catch(() => null)
|
|
752
|
+
: null;
|
|
753
|
+
seen.set(id, resolved && !providerProblem(resolved)
|
|
754
|
+
? {
|
|
755
|
+
basis: (await roleCost(withRoleModel(resolved, preset.model ?? meta.workerModel), preset.model ?? meta.workerModel)).basis,
|
|
756
|
+
// Whether the SDK's dollar figure for this preset IS the bill, or
|
|
757
|
+
// whether it is a gateway that reports through the run's ledger.
|
|
758
|
+
native: resolved.wire === 'anthropic-native',
|
|
759
|
+
}
|
|
760
|
+
: null);
|
|
761
|
+
}
|
|
762
|
+
const cost = seen.get(id);
|
|
763
|
+
if (cost) out[preset.id] = cost;
|
|
764
|
+
}
|
|
765
|
+
return Object.keys(out).length ? out : undefined;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
async function crewEnvsFor(
|
|
769
|
+
meta: RunMeta, ledgerKey: string,
|
|
770
|
+
): Promise<Record<string, ReturnType<typeof providerEnv>> | undefined> {
|
|
771
|
+
const wanted = (meta.crew ?? []).filter((p) => p.providerId);
|
|
772
|
+
if (!wanted.length) return undefined;
|
|
773
|
+
// One resolution per distinct provider, not per preset: two reviewers on the
|
|
774
|
+
// same endpoint are one credential and one gateway.
|
|
775
|
+
const bases = new Map<string, ResolvedProvider | null>();
|
|
776
|
+
const out: Record<string, ReturnType<typeof providerEnv>> = {};
|
|
777
|
+
for (const preset of wanted) {
|
|
778
|
+
const id = preset.providerId as string;
|
|
779
|
+
if (!bases.has(id)) {
|
|
780
|
+
const ref = providerForRole(meta, id);
|
|
781
|
+
// providerForRole answers with the RUN's provider when the id resolves to
|
|
782
|
+
// nothing it knows. That is the right answer for a role, which must run
|
|
783
|
+
// somewhere; here it would quietly pin the preset to a provider nobody
|
|
784
|
+
// asked for, so it counts as "no entry" instead.
|
|
785
|
+
const resolved = 'id' in ref && ref.id === id
|
|
786
|
+
? await resolveProvider(ref, store.root).catch(() => null)
|
|
787
|
+
: null;
|
|
788
|
+
bases.set(id, resolved && !providerProblem(resolved) ? resolved : null);
|
|
789
|
+
}
|
|
790
|
+
const base = bases.get(id);
|
|
791
|
+
if (!base) continue;
|
|
792
|
+
const withModel = withRoleModel(base, preset.model ?? meta.workerModel);
|
|
793
|
+
if (providerProblem(withModel)) continue;
|
|
794
|
+
// A gateway that will not start is the same kind of nothing: the reviewer
|
|
795
|
+
// falls back rather than the run failing.
|
|
796
|
+
const env = await agentEnvFor(withModel, meta.id, ledgerKey).catch(() => null);
|
|
797
|
+
if (env) out[preset.id] = env;
|
|
798
|
+
}
|
|
799
|
+
return Object.keys(out).length ? out : undefined;
|
|
800
|
+
}
|
|
801
|
+
|
|
642
802
|
/**
|
|
643
803
|
* The ledger bucket for this attempt at a run.
|
|
644
804
|
*
|
|
@@ -805,12 +965,24 @@ notifyHub.onAnswer((a) => {
|
|
|
805
965
|
// brief, its budget, its models and its browser judgement.
|
|
806
966
|
void startRun(a.projectId, project.folder, prop.mission, prop.budgetUsd,
|
|
807
967
|
modelChoice(prop.directorModel), modelChoice(prop.workerModel), prop.browser === true,
|
|
808
|
-
providerOf(project), { director: prop.directorProviderId, worker: prop.workerProviderId }
|
|
968
|
+
providerOf(project), { director: prop.directorProviderId, worker: prop.workerProviderId },
|
|
969
|
+
{ startedBy: 'phone' });
|
|
809
970
|
void notifyHub.say(`Started <b>${escTg(project.name)}</b> as proposed, cap $${prop.budgetUsd}.`);
|
|
810
971
|
})();
|
|
811
972
|
}
|
|
812
973
|
});
|
|
813
974
|
|
|
975
|
+
/**
|
|
976
|
+
* A gap as a reader would say it: "in 15 h", "3 days ago". Used where a
|
|
977
|
+
* timestamp would make someone do arithmetic on their phone.
|
|
978
|
+
*/
|
|
979
|
+
function relativeTime(ms: number): string {
|
|
980
|
+
const s = Math.round(ms / 1000);
|
|
981
|
+
const a = Math.abs(s);
|
|
982
|
+
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`;
|
|
983
|
+
return s >= 0 ? `in ${span}` : `${span} ago`;
|
|
984
|
+
}
|
|
985
|
+
|
|
814
986
|
/** The project the phone last planned with; plain text continues it. */
|
|
815
987
|
let lastPhonePlanning: string | null = null;
|
|
816
988
|
|
|
@@ -959,6 +1131,41 @@ const fleetHost: FleetHost = {
|
|
|
959
1131
|
return out;
|
|
960
1132
|
},
|
|
961
1133
|
|
|
1134
|
+
/**
|
|
1135
|
+
* The standing schedules, with what they have already cost this month
|
|
1136
|
+
* against their project's ceiling — the number that decides whether the next
|
|
1137
|
+
* unattended firing happens at all, and the one nobody can see from a phone.
|
|
1138
|
+
*
|
|
1139
|
+
* Reading only, like every other verb here. An unknown reference is an
|
|
1140
|
+
* error rather than an empty list: "no schedules" and "you named a project
|
|
1141
|
+
* that does not exist" are different answers, and a typo must not read as
|
|
1142
|
+
* reassurance.
|
|
1143
|
+
*/
|
|
1144
|
+
async listSchedules(ref?: string): Promise<FleetScheduleView[]> {
|
|
1145
|
+
const project = ref ? await findProject(ref) : null;
|
|
1146
|
+
if (ref && !project) throw new Error(await noSuchProject(ref));
|
|
1147
|
+
const schedules = await store.listSchedules(project?.id);
|
|
1148
|
+
if (!schedules.length) return [];
|
|
1149
|
+
const runs = await store.listRuns().catch(() => [] as RunMeta[]);
|
|
1150
|
+
const now = new Date();
|
|
1151
|
+
const names = new Map((await store.listProjects()).map((p) => [p.id, p.name]));
|
|
1152
|
+
// One settings read and one spend count per project, not per schedule:
|
|
1153
|
+
// a project with six nightly schedules asks the same two questions six
|
|
1154
|
+
// times, and the answers cannot differ between them.
|
|
1155
|
+
const money = new Map<string, { monthSpendUsd: number; monthlyCapUsd: number }>();
|
|
1156
|
+
for (const projectId of new Set(schedules.map((s) => s.projectId))) {
|
|
1157
|
+
money.set(projectId, {
|
|
1158
|
+
monthSpendUsd: monthlyScheduledSpend(runs, projectId, now),
|
|
1159
|
+
monthlyCapUsd: (await effectiveSettings(projectId)).scheduledMonthlyCapUsd,
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
return schedules.map((schedule) => ({
|
|
1163
|
+
projectName: names.get(schedule.projectId) ?? schedule.projectId,
|
|
1164
|
+
schedule,
|
|
1165
|
+
...money.get(schedule.projectId)!,
|
|
1166
|
+
}));
|
|
1167
|
+
},
|
|
1168
|
+
|
|
962
1169
|
async projectDetail(ref) {
|
|
963
1170
|
const project = await findProject(ref);
|
|
964
1171
|
if (!project) return noSuchProject(ref);
|
|
@@ -1008,6 +1215,10 @@ const fleetHost: FleetHost = {
|
|
|
1008
1215
|
];
|
|
1009
1216
|
if (words.error) lines.push(`it stopped with: ${clipText(words.error, 400)}`);
|
|
1010
1217
|
if (last.workers.length) lines.push(`crew: ${last.workers.map((w) => `${w.id} ${w.status}`).join(', ')}`);
|
|
1218
|
+
// Verdicts read out here and nowhere else on the phone: which reviewers
|
|
1219
|
+
// ran is a fact about the run, while which presets exist is configuration,
|
|
1220
|
+
// and configuration is edited on the dashboard.
|
|
1221
|
+
lines.push(...reviewReportLines(last.crew, last.reviews));
|
|
1011
1222
|
if (words.result) lines.push(`director's closing report:\n${clipText(words.result, 2500)}`);
|
|
1012
1223
|
else if (words.last) lines.push(`director's last words:\n${clipText(words.last, 2500)}`);
|
|
1013
1224
|
return lines.join('\n');
|
|
@@ -1246,9 +1457,35 @@ async function handlePhoneText(text: string, replyTo?: string): Promise<void> {
|
|
|
1246
1457
|
if (!reserveProject(project.id)) return say(`<b>${escTg(project.name)}</b> already has an active mission.`);
|
|
1247
1458
|
void startRun(project.id, project.folder, cmd.text, project.defaultBudgetUsd,
|
|
1248
1459
|
modelChoice(undefined), modelChoice(undefined), /screenshot|browser|render|console/i.test(cmd.text),
|
|
1249
|
-
providerOf(project));
|
|
1460
|
+
providerOf(project), {}, { startedBy: 'phone' });
|
|
1250
1461
|
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
1462
|
}
|
|
1463
|
+
case 'schedules': {
|
|
1464
|
+
// Reading only. A schedule is standing configuration — it decides
|
|
1465
|
+
// what a machine does while nobody is watching — and remote surfaces
|
|
1466
|
+
// never grant standing changes, so there is no create, edit, pause,
|
|
1467
|
+
// resume or run-now here. The reply says so rather than leaving
|
|
1468
|
+
// someone to discover it by trying.
|
|
1469
|
+
const project = cmd.project ? await findProject(cmd.project) : null;
|
|
1470
|
+
if (cmd.project && !project) return say(`No project called <b>${escTg(cmd.project)}</b>. /projects lists them.`);
|
|
1471
|
+
const schedules = await store.listSchedules(project?.id);
|
|
1472
|
+
const footer = 'Schedules are read-only from here — create, edit, pause and resume live in the dashboard.';
|
|
1473
|
+
if (!schedules.length) {
|
|
1474
|
+
return say(`No schedules${project ? ` on <b>${escTg(project.name)}</b>` : ''} yet.\n${footer}`);
|
|
1475
|
+
}
|
|
1476
|
+
const names = new Map((await store.listProjects()).map((p) => [p.id, p.name]));
|
|
1477
|
+
const lines = schedules.map((s) => {
|
|
1478
|
+
const state = s.pausedReason
|
|
1479
|
+
? `paused (${s.pausedReason === 'monthly-cap' ? 'monthly cap' : s.pausedReason})`
|
|
1480
|
+
: s.enabled ? 'enabled' : 'disabled';
|
|
1481
|
+
const next = s.pausedReason || !s.enabled || s.nextRunAt === null
|
|
1482
|
+
? 'no next run'
|
|
1483
|
+
: `next ${relativeTime(s.nextRunAt - Date.now())}`;
|
|
1484
|
+
const where = project ? '' : ` · ${escTg(names.get(s.projectId) ?? s.projectId)}`;
|
|
1485
|
+
return `• <b>${escTg(s.name)}</b>${where}\n ${escTg(describeCadence(s.cadence))} · ${next} · ${state}`;
|
|
1486
|
+
});
|
|
1487
|
+
return say(`<b>Schedules${project ? ` · ${escTg(project.name)}` : ''}</b>\n${lines.join('\n')}\n\n${footer}`);
|
|
1488
|
+
}
|
|
1252
1489
|
case 'stop': {
|
|
1253
1490
|
const project = cmd.project ? await findProject(cmd.project) : (lastPhonePlanning ? await store.getProject(lastPhonePlanning) : null);
|
|
1254
1491
|
const abort = project && chatAborts.get(project.id);
|
|
@@ -1451,6 +1688,9 @@ async function driveRun(
|
|
|
1451
1688
|
changes?: { directorChanged: boolean; workerChanged: boolean },
|
|
1452
1689
|
): Promise<void> {
|
|
1453
1690
|
const emit = makeEmitter(meta.id, projectId);
|
|
1691
|
+
// Registered before anything can fail, so even a run that dies on its
|
|
1692
|
+
// provider still tells the emitter which schedule it belonged to.
|
|
1693
|
+
drivingRuns.set(meta.id, meta);
|
|
1454
1694
|
// One resolution per run, from the provider frozen into the run's metadata.
|
|
1455
1695
|
// A run that cannot resolve a credential must not start: dispatching anyway
|
|
1456
1696
|
// would fall back to whatever the environment happens to hold.
|
|
@@ -1462,6 +1702,7 @@ async function driveRun(
|
|
|
1462
1702
|
await store.writeMeta(meta).catch(() => {});
|
|
1463
1703
|
emit('run_error', { error: `provider unavailable — ${problem}` });
|
|
1464
1704
|
emit('run_finished', { status: 'error', costUsd: meta.costUsd });
|
|
1705
|
+
drivingRuns.delete(meta.id);
|
|
1465
1706
|
activeByProject.delete(projectId);
|
|
1466
1707
|
return;
|
|
1467
1708
|
}
|
|
@@ -1491,8 +1732,8 @@ async function driveRun(
|
|
|
1491
1732
|
let agentEnv;
|
|
1492
1733
|
let roleBasis = resolved.costBasis;
|
|
1493
1734
|
let prices: { director?: ModelPrice; worker?: ModelPrice } = {};
|
|
1494
|
-
let roleBases: { director: CostBasis; worker: CostBasis } | undefined;
|
|
1495
|
-
let gatewayRoles = { director: false, worker: false };
|
|
1735
|
+
let roleBases: { director: CostBasis; worker: CostBasis; crew?: Record<string, { basis: CostBasis; native: boolean }> } | undefined;
|
|
1736
|
+
let gatewayRoles: { director: boolean; worker: boolean; crew?: boolean } = { director: false, worker: false };
|
|
1496
1737
|
try {
|
|
1497
1738
|
// Resolved per role. Where both roles share a provider this resolves once
|
|
1498
1739
|
// and starts one gateway; where they differ, the supervisor already runs a
|
|
@@ -1522,11 +1763,15 @@ async function driveRun(
|
|
|
1522
1763
|
worker: directorProvider === workerProvider
|
|
1523
1764
|
? await agentEnvFor(directorProvider, meta.id, key)
|
|
1524
1765
|
: await agentEnvFor(workerProvider, meta.id, key),
|
|
1766
|
+
crew: await crewEnvsFor(meta, key),
|
|
1525
1767
|
};
|
|
1526
1768
|
// Only roles that actually go through a gateway are counted there; a
|
|
1527
1769
|
// native role's tokens arrive on the SDK's own result message, and adding
|
|
1528
1770
|
// both would double every one of them.
|
|
1529
1771
|
gatewayRoles = {
|
|
1772
|
+
// A crew preset on a gateway sends its tokens to the same ledger, so
|
|
1773
|
+
// polling has to run even when both ordinary roles are native.
|
|
1774
|
+
crew: Object.values(await crewBasesFor(meta) ?? {}).some((c) => !c.native),
|
|
1530
1775
|
director: directorProvider.wire !== 'anthropic-native',
|
|
1531
1776
|
worker: workerProvider.wire !== 'anthropic-native',
|
|
1532
1777
|
};
|
|
@@ -1536,13 +1781,14 @@ async function driveRun(
|
|
|
1536
1781
|
: await roleCost(workerProvider, meta.workerModel);
|
|
1537
1782
|
roleBasis = combineBasis(directorCost.basis, workerCost.basis);
|
|
1538
1783
|
prices = { director: directorCost.price, worker: workerCost.price };
|
|
1539
|
-
roleBases = { director: directorCost.basis, worker: workerCost.basis };
|
|
1784
|
+
roleBases = { director: directorCost.basis, worker: workerCost.basis, crew: await crewBasesFor(meta) };
|
|
1540
1785
|
} catch (err) {
|
|
1541
1786
|
meta.status = 'error';
|
|
1542
1787
|
meta.endedAt = Date.now();
|
|
1543
1788
|
await store.writeMeta(meta).catch(() => {});
|
|
1544
1789
|
emit('run_error', { error: String(err instanceof Error ? err.message : err) });
|
|
1545
1790
|
emit('run_finished', { status: 'error', costUsd: meta.costUsd });
|
|
1791
|
+
drivingRuns.delete(meta.id);
|
|
1546
1792
|
activeByProject.delete(projectId);
|
|
1547
1793
|
return;
|
|
1548
1794
|
}
|
|
@@ -1613,6 +1859,7 @@ async function driveRun(
|
|
|
1613
1859
|
} finally {
|
|
1614
1860
|
// However the run ended, it no longer needs its gateways.
|
|
1615
1861
|
releaseGateways(meta.id);
|
|
1862
|
+
drivingRuns.delete(meta.id);
|
|
1616
1863
|
if (activeByProject.get(projectId) === run) activeByProject.delete(projectId);
|
|
1617
1864
|
// Freeze the record: the mission doc and the deck as they stand at this
|
|
1618
1865
|
// moment, beside the run's meta and log. Done first, before the branch
|
|
@@ -1651,8 +1898,14 @@ async function effectiveSettings(projectId: string): Promise<{
|
|
|
1651
1898
|
directorProviderId?: string; workerProviderId?: string;
|
|
1652
1899
|
/** In a repository, each mission runs on a branch of its own (default on). */
|
|
1653
1900
|
gitBranchPerMission: boolean;
|
|
1901
|
+
/** A mission in a worktree may use its parent repository without asking (default on). */
|
|
1902
|
+
allowWorktreeParent: boolean;
|
|
1654
1903
|
/** Percent of the cap at which the director is told to start verifying (default 80). */
|
|
1655
1904
|
budgetWarnAt: number;
|
|
1905
|
+
/** Ceiling on what this project's SCHEDULED runs may cost in one calendar month. */
|
|
1906
|
+
scheduledMonthlyCapUsd: number;
|
|
1907
|
+
/** The crew presets a mission here may be started with (built-ins until edited). */
|
|
1908
|
+
crewPresets: CrewPreset[];
|
|
1656
1909
|
}> {
|
|
1657
1910
|
const s = await store.readSettings()
|
|
1658
1911
|
.catch(() => ({ global: {}, projects: {} as Record<string, object> }));
|
|
@@ -1674,10 +1927,20 @@ async function effectiveSettings(projectId: string): Promise<{
|
|
|
1674
1927
|
directorProviderId: str(p.directorProviderId ?? g.directorProviderId),
|
|
1675
1928
|
workerProviderId: str(p.workerProviderId ?? g.workerProviderId),
|
|
1676
1929
|
gitBranchPerMission: (p.gitBranchPerMission ?? g.gitBranchPerMission) !== false,
|
|
1930
|
+
allowWorktreeParent: (p.allowWorktreeParent ?? g.allowWorktreeParent) !== false,
|
|
1677
1931
|
budgetWarnAt: (() => {
|
|
1678
1932
|
const raw = Number(p.budgetWarnAt ?? g.budgetWarnAt);
|
|
1679
1933
|
return Number.isFinite(raw) && raw > 0 && raw < 100 ? raw : 60;
|
|
1680
1934
|
})(),
|
|
1935
|
+
scheduledMonthlyCapUsd: (() => {
|
|
1936
|
+
// Zero is a legal answer — "this project may not spend unattended at
|
|
1937
|
+
// all" — so only a negative or unreadable value falls back to the default.
|
|
1938
|
+
const raw = Number(p.scheduledMonthlyCapUsd ?? g.scheduledMonthlyCapUsd);
|
|
1939
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : DEFAULT_SCHEDULED_MONTHLY_CAP_USD;
|
|
1940
|
+
})(),
|
|
1941
|
+
// The project's list replaces the global one whole, like the rest of the
|
|
1942
|
+
// overlay — merging would make "no reviewer on this project" unsayable.
|
|
1943
|
+
crewPresets: crewPresetsFrom(g, p),
|
|
1681
1944
|
};
|
|
1682
1945
|
}
|
|
1683
1946
|
|
|
@@ -1697,17 +1960,83 @@ async function gitInfoCached(folder: string): Promise<GitInfo> {
|
|
|
1697
1960
|
return value;
|
|
1698
1961
|
}
|
|
1699
1962
|
|
|
1963
|
+
/**
|
|
1964
|
+
* A mission in a git worktree gets its parent repository without being asked.
|
|
1965
|
+
*
|
|
1966
|
+
* The worktree holds the branch's files; the build config, the shared type
|
|
1967
|
+
* declarations and the parent's node_modules live up in the repository it was
|
|
1968
|
+
* made from. A crew working in a worktree therefore crosses the boundary on
|
|
1969
|
+
* almost every command, and answering the same question all day is not
|
|
1970
|
+
* oversight — it is noise that trains the human to click Allow without
|
|
1971
|
+
* reading. The parent is opened at the start instead, once, in the open: the
|
|
1972
|
+
* transcript records it the same way it records a human's own grant.
|
|
1973
|
+
*
|
|
1974
|
+
* Only the parent. Sibling worktrees stay closed, because one of them may be
|
|
1975
|
+
* another mission's workspace, and so does a parent that a live run is
|
|
1976
|
+
* already working in.
|
|
1977
|
+
*/
|
|
1978
|
+
async function grantWorktreeParent(
|
|
1979
|
+
meta: RunMeta, allowed: boolean, emit: ReturnType<typeof makeEmitter>,
|
|
1980
|
+
): Promise<void> {
|
|
1981
|
+
// Re-derived from scratch every start and every resume, never merely added
|
|
1982
|
+
// to: the setting may have been turned off since, or another mission may
|
|
1983
|
+
// have taken the parent, and a grant that outlives its reason is a hole.
|
|
1984
|
+
// Only Foreman's own grants are withdrawn; a human's stay.
|
|
1985
|
+
const previous = meta.autoRoots ?? [];
|
|
1986
|
+
const shape = allowed ? await worktreeParent(meta.folder).catch(() => null) : null;
|
|
1987
|
+
const busy = activeRuns().filter((r) => r.meta.id !== meta.id).map((r) => r.meta.folder);
|
|
1988
|
+
const decision = worktreeGrant(shape, busy);
|
|
1989
|
+
const now = decision.grant ? [decision.grant] : [];
|
|
1990
|
+
const withdrawn = previous.filter((p) => !now.includes(p));
|
|
1991
|
+
|
|
1992
|
+
if (withdrawn.length || now.some((p) => !previous.includes(p))) {
|
|
1993
|
+
meta.allowedRoots = [...(meta.allowedRoots ?? []).filter((p) => !previous.includes(p)), ...now];
|
|
1994
|
+
meta.autoRoots = now;
|
|
1995
|
+
await store.writeMeta(meta).catch(() => {});
|
|
1996
|
+
}
|
|
1997
|
+
for (const p of withdrawn) {
|
|
1998
|
+
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.` });
|
|
1999
|
+
}
|
|
2000
|
+
if (decision.grant && !previous.includes(decision.grant)) {
|
|
2001
|
+
emit('root_allowed', {
|
|
2002
|
+
path: decision.grant, agent: 'foreman',
|
|
2003
|
+
reason: 'this mission runs in a worktree of that repository',
|
|
2004
|
+
});
|
|
2005
|
+
} else if (!decision.grant && decision.reason && !withdrawn.length) {
|
|
2006
|
+
emit('git_note', { text: `Left closed: ${decision.reason}. The crew will ask before it steps outside the mission folder.` });
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
|
|
1700
2010
|
async function startRun(
|
|
1701
2011
|
projectId: string, folder: string, mission: string, budgetUsd: number,
|
|
1702
2012
|
directorModel: ModelChoice, workerModel: ModelChoice, browserTools: boolean,
|
|
1703
2013
|
provider: ProviderRef,
|
|
1704
2014
|
roleProviders: { director?: string; worker?: string } = {},
|
|
2015
|
+
/**
|
|
2016
|
+
* How this run began. Recorded rather than inferred: an unattended run and
|
|
2017
|
+
* one somebody is watching deserve different treatment later, and the
|
|
2018
|
+
* schedule's monthly ceiling can only count what says it was scheduled.
|
|
2019
|
+
*/
|
|
2020
|
+
origin: {
|
|
2021
|
+
startedBy?: 'human' | 'phone' | 'schedule' | 'mcp';
|
|
2022
|
+
scheduleId?: string;
|
|
2023
|
+
scheduleName?: string;
|
|
2024
|
+
} = {},
|
|
2025
|
+
/**
|
|
2026
|
+
* Crew presets the human opted this mission into, by id. Resolved and copied
|
|
2027
|
+
* onto the record here and nowhere else: the run is reviewed against the
|
|
2028
|
+
* presets as they stood when it started, whatever Settings says later.
|
|
2029
|
+
*/
|
|
2030
|
+
crewIds?: readonly string[],
|
|
1705
2031
|
): Promise<void> {
|
|
1706
2032
|
const settings = await effectiveSettings(projectId);
|
|
2033
|
+
if (origin.scheduleId && origin.scheduleName) scheduleNames.set(origin.scheduleId, origin.scheduleName);
|
|
1707
2034
|
const meta: RunMeta = {
|
|
1708
2035
|
id: newRunId(),
|
|
1709
2036
|
projectId,
|
|
1710
2037
|
folder, mission, budgetUsd,
|
|
2038
|
+
startedBy: origin.startedBy ?? 'human',
|
|
2039
|
+
...(origin.scheduleId ? { scheduleId: origin.scheduleId } : {}),
|
|
1711
2040
|
// An explicit composer choice wins; "Default" inherits from Settings.
|
|
1712
2041
|
directorModel: directorModel ?? settings.directorModel,
|
|
1713
2042
|
workerModel: workerModel ?? settings.workerModel,
|
|
@@ -1724,6 +2053,13 @@ async function startRun(
|
|
|
1724
2053
|
// must not silently move an in-flight or resumed run to another provider,
|
|
1725
2054
|
// or another bill.
|
|
1726
2055
|
provider,
|
|
2056
|
+
// Frozen for the same reason as the provider, and against a stronger
|
|
2057
|
+
// temptation: a preset edited next week must not change what a run that is
|
|
2058
|
+
// still going — or one that finished in March — was reviewed against.
|
|
2059
|
+
...(() => {
|
|
2060
|
+
const crew = frozenCrewFor(settings.crewPresets, crewIds);
|
|
2061
|
+
return crew ? { crew } : {};
|
|
2062
|
+
})(),
|
|
1727
2063
|
status: 'running', costUsd: 0,
|
|
1728
2064
|
createdAt: Date.now(), workers: [],
|
|
1729
2065
|
};
|
|
@@ -1734,6 +2070,23 @@ async function startRun(
|
|
|
1734
2070
|
console.error(`failed to create run for project ${projectId}:`, err);
|
|
1735
2071
|
return;
|
|
1736
2072
|
}
|
|
2073
|
+
// Whoever reads this transcript later did not start this run, and the first
|
|
2074
|
+
// question they will have is who did. It is the opening line, before the
|
|
2075
|
+
// branch note, so the answer is at the top rather than buried in the meta.
|
|
2076
|
+
if (origin.scheduleId) {
|
|
2077
|
+
const emit = makeEmitter(meta.id, projectId);
|
|
2078
|
+
emit('run_note', {
|
|
2079
|
+
text: `Started by schedule ${origin.scheduleName ?? scheduleNames.get(origin.scheduleId) ?? origin.scheduleId}.`,
|
|
2080
|
+
});
|
|
2081
|
+
}
|
|
2082
|
+
// The schedule's "last run" is this one, recorded the moment it exists
|
|
2083
|
+
// rather than when it ends: a schedule whose mission is still running should
|
|
2084
|
+
// point at it, not at the one before.
|
|
2085
|
+
if (origin.scheduleId) {
|
|
2086
|
+
await store.updateSchedule(origin.scheduleId, {
|
|
2087
|
+
lastRunId: meta.id, lastRunAt: meta.createdAt, lastNote: '',
|
|
2088
|
+
}).catch(() => {});
|
|
2089
|
+
}
|
|
1737
2090
|
await consumeProposal(projectId, meta.id, mission).catch(() => {});
|
|
1738
2091
|
// The folder's MISSION.md belongs to whichever run wrote it. Parked into
|
|
1739
2092
|
// that run's record (when it lacks one) and cleared, so this run's status
|
|
@@ -1767,6 +2120,7 @@ async function startRun(
|
|
|
1767
2120
|
}
|
|
1768
2121
|
}
|
|
1769
2122
|
}
|
|
2123
|
+
await grantWorktreeParent(meta, settings.allowWorktreeParent, makeEmitter(meta.id, projectId));
|
|
1770
2124
|
await driveRun(projectId, meta);
|
|
1771
2125
|
}
|
|
1772
2126
|
|
|
@@ -1827,6 +2181,7 @@ async function resumeRun(projectId: string, meta: RunMeta, pick: {
|
|
|
1827
2181
|
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
2182
|
gitInfoCache.delete(meta.folder);
|
|
1829
2183
|
}
|
|
2184
|
+
await grantWorktreeParent(meta, (await effectiveSettings(projectId)).allowWorktreeParent, makeEmitter(meta.id, projectId));
|
|
1830
2185
|
// The director resumes from MISSION.md. If another mission ran here since,
|
|
1831
2186
|
// the folder's copy is that mission's; this run's own goes back first.
|
|
1832
2187
|
const restored = await restoreMissionDoc(store.runDirectory(meta.id), meta.folder).catch(() => 'none' as const);
|
|
@@ -1845,6 +2200,153 @@ async function resumeRun(projectId: string, meta: RunMeta, pick: {
|
|
|
1845
2200
|
});
|
|
1846
2201
|
}
|
|
1847
2202
|
|
|
2203
|
+
// ---------------------------------------------------------------------------
|
|
2204
|
+
// Schedules
|
|
2205
|
+
// ---------------------------------------------------------------------------
|
|
2206
|
+
|
|
2207
|
+
/**
|
|
2208
|
+
* Starts a schedule's mission. The project must already be reserved.
|
|
2209
|
+
*
|
|
2210
|
+
* The schedule's own choices win; anything it leaves open falls back to the
|
|
2211
|
+
* project's effective settings, which is what startRun does with an absent
|
|
2212
|
+
* model anyway. The browser judgement is the same heuristic the phone's /run
|
|
2213
|
+
* uses, and for the same reason: there is no box for anyone to tick.
|
|
2214
|
+
*/
|
|
2215
|
+
function startScheduledRun(s: Schedule, project: Project, startedBy: 'human' | 'schedule'): void {
|
|
2216
|
+
void startRun(
|
|
2217
|
+
s.projectId, project.folder, s.brief, s.budgetUsd,
|
|
2218
|
+
modelChoice(s.directorModel), modelChoice(s.workerModel),
|
|
2219
|
+
/screenshot|browser|render|console/i.test(s.brief),
|
|
2220
|
+
providerOf(project),
|
|
2221
|
+
{ director: s.directorProviderId, worker: s.workerProviderId },
|
|
2222
|
+
{ startedBy, scheduleId: s.id, scheduleName: s.name },
|
|
2223
|
+
);
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
/** The next firing of this cadence as a timestamp, or null when there is none. */
|
|
2227
|
+
function nextRunAtMs(cadence: Cadence, after: Date): number | null {
|
|
2228
|
+
const next = nextRunAt(cadence, after);
|
|
2229
|
+
return next ? next.getTime() : null;
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
/**
|
|
2233
|
+
* A scheduled run ended: the schedule remembers how it went.
|
|
2234
|
+
*
|
|
2235
|
+
* Two failures in a row pause it — see afterRunOutcome. Called from the
|
|
2236
|
+
* emitter, which is where the server learns a run finished; the orchestrator
|
|
2237
|
+
* knows nothing about schedules and should not have to.
|
|
2238
|
+
*/
|
|
2239
|
+
async function recordScheduleOutcome(scheduleId: string, meta: RunMeta): Promise<void> {
|
|
2240
|
+
const s = await store.getSchedule(scheduleId);
|
|
2241
|
+
if (!s) return;
|
|
2242
|
+
const outcome = afterRunOutcome(s, { status: meta.status, stopReason: meta.stopReason });
|
|
2243
|
+
await store.updateSchedule(s.id, {
|
|
2244
|
+
lastRunId: meta.id,
|
|
2245
|
+
lastRunAt: meta.endedAt ?? Date.now(),
|
|
2246
|
+
lastOutcome: meta.status === 'running' ? undefined : meta.status,
|
|
2247
|
+
// Whatever the last tick could not do, it did this time: the note would
|
|
2248
|
+
// otherwise still read "project busy" beside a run that just finished.
|
|
2249
|
+
lastNote: '',
|
|
2250
|
+
consecutiveFailures: outcome.consecutiveFailures,
|
|
2251
|
+
pausedReason: outcome.pausedReason,
|
|
2252
|
+
});
|
|
2253
|
+
if (outcome.pausedNow) {
|
|
2254
|
+
const emit = scheduleNotice(s.projectId);
|
|
2255
|
+
emit('schedule_paused', { scheduleId: s.id, name: s.name, projectId: s.projectId, reason: 'failures' });
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
/**
|
|
2260
|
+
* One pass of the ticker: what is due, and what to do about it.
|
|
2261
|
+
*
|
|
2262
|
+
* Passes must not overlap. A pass awaits the store several times, and two of
|
|
2263
|
+
* them interleaving could both see the same schedule as due and start it
|
|
2264
|
+
* twice — the project reservation would catch that, but as a 409 nobody is
|
|
2265
|
+
* there to read rather than as a rule.
|
|
2266
|
+
*/
|
|
2267
|
+
let tickInFlight = false;
|
|
2268
|
+
async function scheduleTick(): Promise<void> {
|
|
2269
|
+
if (tickInFlight) return;
|
|
2270
|
+
tickInFlight = true;
|
|
2271
|
+
try {
|
|
2272
|
+
const schedules = await store.listSchedules();
|
|
2273
|
+
if (!schedules.length) return;
|
|
2274
|
+
for (const s of schedules) scheduleNames.set(s.id, s.name);
|
|
2275
|
+
const now = new Date();
|
|
2276
|
+
// A schedule with no next firing would sit dormant for ever — a record
|
|
2277
|
+
// written by an older build, or one whose cadence was never scheduled.
|
|
2278
|
+
// Giving it one here costs a write once and never again.
|
|
2279
|
+
for (const s of schedules) {
|
|
2280
|
+
if (!s.enabled || s.pausedReason !== null || s.nextRunAt !== null) continue;
|
|
2281
|
+
s.nextRunAt = nextRunAtMs(s.cadence, now);
|
|
2282
|
+
await store.updateSchedule(s.id, { nextRunAt: s.nextRunAt }).catch(() => {});
|
|
2283
|
+
}
|
|
2284
|
+
const runs = await store.listRuns().catch(() => [] as RunMeta[]);
|
|
2285
|
+
const monthSpend = new Map<string, number>();
|
|
2286
|
+
const caps = new Map<string, number>();
|
|
2287
|
+
for (const projectId of new Set(schedules.map((s) => s.projectId))) {
|
|
2288
|
+
monthSpend.set(projectId, monthlyScheduledSpend(runs, projectId, now));
|
|
2289
|
+
caps.set(projectId, (await effectiveSettings(projectId)).scheduledMonthlyCapUsd);
|
|
2290
|
+
}
|
|
2291
|
+
const actions = decideTicks({
|
|
2292
|
+
schedules,
|
|
2293
|
+
now,
|
|
2294
|
+
busyProjectIds: new Set(activeByProject.keys()),
|
|
2295
|
+
monthSpend,
|
|
2296
|
+
capFor: (projectId) => caps.get(projectId) ?? DEFAULT_SCHEDULED_MONTHLY_CAP_USD,
|
|
2297
|
+
});
|
|
2298
|
+
for (const action of actions) {
|
|
2299
|
+
const s = schedules.find((x) => x.id === action.scheduleId);
|
|
2300
|
+
if (!s) continue;
|
|
2301
|
+
if (action.kind === 'pause') {
|
|
2302
|
+
await store.updateSchedule(s.id, { pausedReason: action.reason }).catch(() => {});
|
|
2303
|
+
const emit = scheduleNotice(s.projectId);
|
|
2304
|
+
emit('schedule_paused', {
|
|
2305
|
+
scheduleId: s.id, name: s.name, projectId: s.projectId, reason: action.reason,
|
|
2306
|
+
});
|
|
2307
|
+
continue;
|
|
2308
|
+
}
|
|
2309
|
+
const skipped = async (nextAt: number | null) => {
|
|
2310
|
+
await store.updateSchedule(s.id, {
|
|
2311
|
+
nextRunAt: nextAt, lastOutcome: 'skipped', lastNote: 'project busy',
|
|
2312
|
+
}).catch(() => {});
|
|
2313
|
+
const emit = scheduleNotice(s.projectId);
|
|
2314
|
+
emit('schedule_skipped', {
|
|
2315
|
+
scheduleId: s.id, name: s.name, projectId: s.projectId, reason: 'project busy',
|
|
2316
|
+
});
|
|
2317
|
+
};
|
|
2318
|
+
if (action.kind === 'skip') { await skipped(action.nextRunAt); continue; }
|
|
2319
|
+
const project = await store.getProject(s.projectId).catch(() => null);
|
|
2320
|
+
if (!project) {
|
|
2321
|
+
// The project was unlinked and this schedule outlived it. Nothing to
|
|
2322
|
+
// announce; move it along so the tick does not repeat every 30s.
|
|
2323
|
+
await store.updateSchedule(s.id, { nextRunAt: action.nextRunAt }).catch(() => {});
|
|
2324
|
+
continue;
|
|
2325
|
+
}
|
|
2326
|
+
// Reservation is the last step before dispatch — no awaits in between,
|
|
2327
|
+
// exactly as in POST /run. It can still fail: another dispatch may have
|
|
2328
|
+
// taken the project since this pass read the active map, and that is the
|
|
2329
|
+
// skip case, not a tick to drop on the floor.
|
|
2330
|
+
if (!reserveProject(s.projectId)) { await skipped(action.nextRunAt); continue; }
|
|
2331
|
+
startScheduledRun(s, project, 'schedule');
|
|
2332
|
+
await store.updateSchedule(s.id, { nextRunAt: action.nextRunAt }).catch(() => {});
|
|
2333
|
+
}
|
|
2334
|
+
} catch (err) {
|
|
2335
|
+
// A ticker that throws is a ticker that stops. Nothing here is worth the
|
|
2336
|
+
// schedules of every other project.
|
|
2337
|
+
console.error('schedule tick failed:', err);
|
|
2338
|
+
} finally {
|
|
2339
|
+
tickInFlight = false;
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
// Every half minute, and once shortly after startup so a firing missed while
|
|
2344
|
+
// the machine was off is caught up rather than waiting for the next slot. The
|
|
2345
|
+
// first pass is delayed: the orphan sweep and the store's own startup work
|
|
2346
|
+
// come first, and a schedule is never so urgent that ten seconds matter.
|
|
2347
|
+
setTimeout(() => void scheduleTick(), 10_000).unref();
|
|
2348
|
+
setInterval(() => void scheduleTick(), 30_000).unref();
|
|
2349
|
+
|
|
1848
2350
|
// ---------------------------------------------------------------------------
|
|
1849
2351
|
// Static files
|
|
1850
2352
|
// ---------------------------------------------------------------------------
|
|
@@ -1928,6 +2430,52 @@ async function browseRoots(): Promise<string[]> {
|
|
|
1928
2430
|
return roots;
|
|
1929
2431
|
}
|
|
1930
2432
|
|
|
2433
|
+
/**
|
|
2434
|
+
* A schedule's writable fields out of a request body, or the first thing wrong
|
|
2435
|
+
* with them. `partial` is the PUT: an absent field means "leave it alone",
|
|
2436
|
+
* where on the POST it means the schedule would be missing something it needs.
|
|
2437
|
+
*
|
|
2438
|
+
* The cadence goes through validateCadence rather than being trusted, because
|
|
2439
|
+
* the failure this guards against is silent — a cron expression nobody can
|
|
2440
|
+
* parse becomes a schedule that simply never fires, and looks healthy doing it.
|
|
2441
|
+
*/
|
|
2442
|
+
function parseScheduleBody(
|
|
2443
|
+
b: Record<string, unknown>, partial: boolean,
|
|
2444
|
+
): { error: string } | { fields: Partial<Schedule> } {
|
|
2445
|
+
const fields: Partial<Schedule> = {};
|
|
2446
|
+
for (const key of ['name', 'brief'] as const) {
|
|
2447
|
+
if (b[key] === undefined) {
|
|
2448
|
+
if (!partial) return { error: `${key} is required` };
|
|
2449
|
+
continue;
|
|
2450
|
+
}
|
|
2451
|
+
const v = typeof b[key] === 'string' ? (b[key] as string).trim() : '';
|
|
2452
|
+
if (!v) return { error: `${key} must not be empty` };
|
|
2453
|
+
fields[key] = v;
|
|
2454
|
+
}
|
|
2455
|
+
if (b.budgetUsd !== undefined) {
|
|
2456
|
+
const n = Number(b.budgetUsd);
|
|
2457
|
+
if (!Number.isFinite(n) || n <= 0) return { error: 'budgetUsd must be a positive number' };
|
|
2458
|
+
fields.budgetUsd = n;
|
|
2459
|
+
} else if (!partial) {
|
|
2460
|
+
return { error: 'budgetUsd is required' };
|
|
2461
|
+
}
|
|
2462
|
+
if (b.cadence !== undefined) {
|
|
2463
|
+
const parsed = validateCadence(b.cadence);
|
|
2464
|
+
if (!parsed.ok) return { error: parsed.error };
|
|
2465
|
+
fields.cadence = parsed.cadence;
|
|
2466
|
+
} else if (!partial) {
|
|
2467
|
+
return { error: 'cadence is required' };
|
|
2468
|
+
}
|
|
2469
|
+
for (const key of ['directorModel', 'workerModel'] as const) {
|
|
2470
|
+
if (typeof b[key] === 'string' && (b[key] as string).trim()) fields[key] = modelChoice(b[key]);
|
|
2471
|
+
}
|
|
2472
|
+
for (const key of ['directorProviderId', 'workerProviderId'] as const) {
|
|
2473
|
+
if (typeof b[key] === 'string' && (b[key] as string).trim()) fields[key] = (b[key] as string).trim();
|
|
2474
|
+
}
|
|
2475
|
+
if (typeof b.enabled === 'boolean') fields.enabled = b.enabled;
|
|
2476
|
+
return { fields };
|
|
2477
|
+
}
|
|
2478
|
+
|
|
1931
2479
|
function json(res: http.ServerResponse, code: number, body: unknown): void {
|
|
1932
2480
|
res.writeHead(code, { 'content-type': 'application/json' });
|
|
1933
2481
|
res.end(JSON.stringify(body));
|
|
@@ -1969,6 +2517,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1969
2517
|
const stopServiceMatch = url.pathname.match(/^\/runs\/([^/]+)\/services\/(\d{1,5})\/stop$/);
|
|
1970
2518
|
const projectMatch = url.pathname.match(/^\/projects\/([^/]+)$/);
|
|
1971
2519
|
const providerKeyMatch = url.pathname.match(/^\/providers\/([A-Za-z0-9_-]{1,64})\/key$/);
|
|
2520
|
+
const projectSchedulesMatch = url.pathname.match(/^\/projects\/([^/]+)\/schedules$/);
|
|
2521
|
+
const scheduleMatch = url.pathname.match(/^\/schedules\/([^/]+)$/);
|
|
2522
|
+
const scheduleActionMatch = url.pathname.match(/^\/schedules\/([^/]+)\/(pause|resume|run-now)$/);
|
|
1972
2523
|
|
|
1973
2524
|
try {
|
|
1974
2525
|
if (req.method === 'GET' && (url.pathname === '/'
|
|
@@ -2103,6 +2654,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
2103
2654
|
createdAt: lastRun.createdAt, costUsd: lastRun.costUsd,
|
|
2104
2655
|
// The card may print a dollar only where the dollar was real.
|
|
2105
2656
|
costBasis: costBasisOf(lastRun), usage: lastRun.usage,
|
|
2657
|
+
// Not the verdicts themselves — see reviewedByNames. The tile
|
|
2658
|
+
// renders a glyph and a name, and this projection is polled for
|
|
2659
|
+
// every project every few seconds.
|
|
2660
|
+
reviewedBy: reviewedByNames(lastRun),
|
|
2106
2661
|
},
|
|
2107
2662
|
// When this project last did anything, so the fleet can lead with it.
|
|
2108
2663
|
// A planner parked on a question is doing something — waiting on
|
|
@@ -2252,7 +2807,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
2252
2807
|
} else if (req.method === 'POST' && url.pathname === '/run') {
|
|
2253
2808
|
const {
|
|
2254
2809
|
projectId, mission, budgetUsd, directorModel, workerModel, browserTools,
|
|
2255
|
-
directorProviderId, workerProviderId, allowDirty,
|
|
2810
|
+
directorProviderId, workerProviderId, allowDirty, startedBy, crew,
|
|
2256
2811
|
} = await readBody(req);
|
|
2257
2812
|
if (typeof projectId !== 'string' || typeof mission !== 'string' || !mission.trim()) {
|
|
2258
2813
|
return json(res, 400, { error: 'projectId and mission are required' });
|
|
@@ -2294,7 +2849,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
2294
2849
|
{
|
|
2295
2850
|
director: typeof directorProviderId === 'string' ? directorProviderId : undefined,
|
|
2296
2851
|
worker: typeof workerProviderId === 'string' ? workerProviderId : undefined,
|
|
2297
|
-
}
|
|
2852
|
+
},
|
|
2853
|
+
// 'schedule' is deliberately not accepted here: a caller must not be
|
|
2854
|
+
// able to forge a scheduled start and charge the month's unattended
|
|
2855
|
+
// allowance for a run no schedule asked for.
|
|
2856
|
+
{ startedBy: startedBy === 'phone' || startedBy === 'mcp' ? startedBy : 'human' },
|
|
2857
|
+
// The composer is the only place a crew is chosen, so it is the only
|
|
2858
|
+
// dispatch path that carries one; unknown ids are dropped downstream.
|
|
2859
|
+
Array.isArray(crew) ? crew as string[] : undefined);
|
|
2298
2860
|
json(res, 200, { ok: true });
|
|
2299
2861
|
|
|
2300
2862
|
} else if (url.pathname === '/notify' && req.method === 'GET') {
|
|
@@ -2763,7 +3325,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
2763
3325
|
// mission was branched from — see resolvePrBase.
|
|
2764
3326
|
const target = await resolvePrBase(meta.folder, meta.git.base);
|
|
2765
3327
|
json(res, 200, {
|
|
2766
|
-
...prDraft(meta, doc), branch: meta.git.branch, base: target.base, commits: meta.git.commits ?? null,
|
|
3328
|
+
...prDraft(meta, doc, meta.reviews), branch: meta.git.branch, base: target.base, commits: meta.git.commits ?? null,
|
|
2767
3329
|
branchedFrom: meta.git.base, baseFellBack: target.fellBack,
|
|
2768
3330
|
remote: info.remote, compareUrl: compareUrl(info.remote, target.base, meta.git.branch),
|
|
2769
3331
|
gh, pr: meta.git.pr ?? null, onBranch: info.branch === meta.git.branch, dirty: Boolean(info.dirty),
|
|
@@ -2901,6 +3463,107 @@ const server = http.createServer(async (req, res) => {
|
|
|
2901
3463
|
if (!project) return json(res, 404, { error: 'unknown project' });
|
|
2902
3464
|
json(res, 200, await readMemory(project.folder));
|
|
2903
3465
|
|
|
3466
|
+
} else if (req.method === 'GET' && url.pathname === '/schedules/preview') {
|
|
3467
|
+
// The picker's "next three runs". Deliberately the same function the
|
|
3468
|
+
// ticker fires from, so what the human is shown and what will actually
|
|
3469
|
+
// happen cannot drift apart.
|
|
3470
|
+
let cadence: unknown;
|
|
3471
|
+
try { cadence = JSON.parse(url.searchParams.get('cadence') ?? ''); }
|
|
3472
|
+
catch { return json(res, 400, { error: 'cadence must be a JSON object in the query string' }); }
|
|
3473
|
+
const parsed = validateCadence(cadence);
|
|
3474
|
+
if (!parsed.ok) return json(res, 400, { error: parsed.error });
|
|
3475
|
+
json(res, 200, { next: nextRuns(parsed.cadence, new Date(), 3).map((d) => d.getTime()) });
|
|
3476
|
+
|
|
3477
|
+
} else if (req.method === 'GET' && projectSchedulesMatch) {
|
|
3478
|
+
const projectId = projectSchedulesMatch[1];
|
|
3479
|
+
if (!await store.getProject(projectId)) return json(res, 404, { error: 'unknown project' });
|
|
3480
|
+
const [schedules, runs, settings] = await Promise.all([
|
|
3481
|
+
store.listSchedules(projectId), store.listRuns().catch(() => [] as RunMeta[]), effectiveSettings(projectId),
|
|
3482
|
+
]);
|
|
3483
|
+
json(res, 200, {
|
|
3484
|
+
schedules,
|
|
3485
|
+
monthSpendUsd: monthlyScheduledSpend(runs, projectId, new Date()),
|
|
3486
|
+
monthlyCapUsd: settings.scheduledMonthlyCapUsd,
|
|
3487
|
+
});
|
|
3488
|
+
|
|
3489
|
+
} else if (req.method === 'POST' && projectSchedulesMatch) {
|
|
3490
|
+
const projectId = projectSchedulesMatch[1];
|
|
3491
|
+
if (!await store.getProject(projectId)) return json(res, 404, { error: 'unknown project' });
|
|
3492
|
+
const parsed = parseScheduleBody(await readBody(req), false);
|
|
3493
|
+
if ('error' in parsed) return json(res, 400, { error: parsed.error });
|
|
3494
|
+
const f = parsed.fields;
|
|
3495
|
+
const schedule = await store.addSchedule({
|
|
3496
|
+
projectId,
|
|
3497
|
+
name: f.name!, brief: f.brief!, cadence: f.cadence!, budgetUsd: f.budgetUsd!,
|
|
3498
|
+
directorModel: f.directorModel, workerModel: f.workerModel,
|
|
3499
|
+
directorProviderId: f.directorProviderId, workerProviderId: f.workerProviderId,
|
|
3500
|
+
// Enabled unless the caller said otherwise: a schedule nobody switched
|
|
3501
|
+
// on is a form somebody filled in and forgot.
|
|
3502
|
+
enabled: f.enabled !== false,
|
|
3503
|
+
nextRunAt: f.enabled === false ? null : nextRunAtMs(f.cadence!, new Date()),
|
|
3504
|
+
consecutiveFailures: 0,
|
|
3505
|
+
pausedReason: null,
|
|
3506
|
+
});
|
|
3507
|
+
scheduleNames.set(schedule.id, schedule.name);
|
|
3508
|
+
json(res, 200, { schedule });
|
|
3509
|
+
|
|
3510
|
+
} else if (req.method === 'PUT' && scheduleMatch) {
|
|
3511
|
+
const existing = await store.getSchedule(scheduleMatch[1]);
|
|
3512
|
+
if (!existing) return json(res, 404, { error: 'unknown schedule' });
|
|
3513
|
+
const parsed = parseScheduleBody(await readBody(req), true);
|
|
3514
|
+
if ('error' in parsed) return json(res, 400, { error: parsed.error });
|
|
3515
|
+
const f = parsed.fields;
|
|
3516
|
+
const enabled = f.enabled ?? existing.enabled;
|
|
3517
|
+
const patch: Partial<Schedule> = { ...f };
|
|
3518
|
+
// A changed cadence is a changed answer to "when next?", counted from
|
|
3519
|
+
// now: keeping the old firing time would mean the schedule the human
|
|
3520
|
+
// just moved fires one more time on the schedule they moved it off.
|
|
3521
|
+
if (f.cadence || f.enabled !== undefined) {
|
|
3522
|
+
patch.nextRunAt = enabled && existing.pausedReason === null
|
|
3523
|
+
? nextRunAtMs(f.cadence ?? existing.cadence, new Date())
|
|
3524
|
+
: null;
|
|
3525
|
+
}
|
|
3526
|
+
const schedule = await store.updateSchedule(existing.id, patch);
|
|
3527
|
+
if (schedule) scheduleNames.set(schedule.id, schedule.name);
|
|
3528
|
+
json(res, 200, { schedule });
|
|
3529
|
+
|
|
3530
|
+
} else if (req.method === 'DELETE' && scheduleMatch) {
|
|
3531
|
+
const removed = await store.removeSchedule(scheduleMatch[1]);
|
|
3532
|
+
json(res, removed ? 200 : 404, removed ? { ok: true } : { error: 'unknown schedule' });
|
|
3533
|
+
|
|
3534
|
+
} else if (req.method === 'POST' && scheduleActionMatch) {
|
|
3535
|
+
const [, scheduleId, action] = scheduleActionMatch;
|
|
3536
|
+
const s = await store.getSchedule(scheduleId);
|
|
3537
|
+
if (!s) return json(res, 404, { error: 'unknown schedule' });
|
|
3538
|
+
if (action === 'pause') {
|
|
3539
|
+
json(res, 200, { schedule: await store.updateSchedule(s.id, { pausedReason: 'human', nextRunAt: null }) });
|
|
3540
|
+
|
|
3541
|
+
} else if (action === 'resume') {
|
|
3542
|
+
// Resuming is a dashboard act and has no remote equivalent on purpose:
|
|
3543
|
+
// whatever paused this — the human, the month's ceiling, two failures
|
|
3544
|
+
// in a row — is a thing to look at before it runs unattended again.
|
|
3545
|
+
json(res, 200, {
|
|
3546
|
+
schedule: await store.updateSchedule(s.id, {
|
|
3547
|
+
pausedReason: null,
|
|
3548
|
+
consecutiveFailures: 0,
|
|
3549
|
+
nextRunAt: s.enabled ? nextRunAtMs(s.cadence, new Date()) : null,
|
|
3550
|
+
}),
|
|
3551
|
+
});
|
|
3552
|
+
|
|
3553
|
+
} else {
|
|
3554
|
+
const project = await store.getProject(s.projectId);
|
|
3555
|
+
if (!project) return json(res, 404, { error: 'unknown project' });
|
|
3556
|
+
// Allowed even at the monthly ceiling, and not counted against it
|
|
3557
|
+
// beforehand: the ceiling governs UNATTENDED spending, and a human
|
|
3558
|
+
// pressing a button is by definition not that. The run still carries
|
|
3559
|
+
// the scheduleId, so what it costs does count towards the month.
|
|
3560
|
+
if (!reserveProject(s.projectId)) {
|
|
3561
|
+
return json(res, 409, { error: 'this project already has an active mission' });
|
|
3562
|
+
}
|
|
3563
|
+
startScheduledRun(s, project, 'human');
|
|
3564
|
+
json(res, 200, { ok: true });
|
|
3565
|
+
}
|
|
3566
|
+
|
|
2904
3567
|
} else if (req.method === 'GET' && url.pathname === '/missiondoc') {
|
|
2905
3568
|
const runId = url.searchParams.get('run');
|
|
2906
3569
|
if (!runId) return json(res, 400, { error: 'run parameter is required' });
|