omp-conductor 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +253 -84
- package/package.json +1 -1
- package/src/availability.ts +165 -0
- package/src/board.ts +1 -1
- package/src/briefs/orchestrator.md +95 -28
- package/src/briefs/policy.md +44 -32
- package/src/cli.ts +226 -37
- package/src/config.ts +123 -7
- package/src/daemon.ts +1018 -145
- package/src/diff-flags.ts +77 -4
- package/src/digest-schedule.ts +92 -24
- package/src/escalate.ts +46 -19
- package/src/failure-class.ts +7 -5
- package/src/fleet.ts +39 -3
- package/src/omp.ts +8 -4
- package/src/orchestrator-tick.ts +471 -39
- package/src/orchestrator.ts +3 -2
- package/src/plugin.ts +166 -14
- package/src/release-policy.ts +66 -6
- package/src/reports.ts +202 -5
- package/src/session-host.ts +4 -3
- package/src/setup.ts +197 -35
- package/src/store.ts +785 -112
- package/src/tracker/github.ts +299 -56
- package/src/types.ts +289 -37
- package/src/verbs/actions.ts +245 -15
- package/src/verbs/protocol.ts +7 -6
- package/src/verbs/server.ts +94 -13
- package/src/worker.ts +51 -11
- package/src/worktree.ts +5 -0
package/src/cli.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* process lifecycle in ./lifecycle.ts, so the CLI and the `/conductor` plugin
|
|
6
6
|
* cannot drift apart.
|
|
7
7
|
*/
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
8
9
|
import { closeSync, openSync, readFileSync, readSync, statSync } from "node:fs";
|
|
9
10
|
import { userInfo } from "node:os";
|
|
10
11
|
import { dirname, join } from "node:path";
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
proposeRetrofit,
|
|
22
23
|
repairPolicyBannerCrumbs,
|
|
23
24
|
} from "./brief-upgrade.ts";
|
|
25
|
+
import { interruptDisposition } from "./availability.ts";
|
|
24
26
|
import { findProject, loadConfig, resolveCaps, stateDir } from "./config.ts";
|
|
25
27
|
import { CONDITION_FORMS, parseCondition } from "./decisions.ts";
|
|
26
28
|
import { isPaused, pausedAt, runDaemon, setPaused } from "./daemon.ts";
|
|
@@ -106,6 +108,7 @@ usage:
|
|
|
106
108
|
omp-conductor extend <issue> --turns N [--project NAME]
|
|
107
109
|
omp-conductor worker pause <issue> [--project NAME]
|
|
108
110
|
omp-conductor worker resume <issue> [--project NAME]
|
|
111
|
+
omp-conductor worker stop <issue> --reason TEXT [--project NAME]
|
|
109
112
|
omp-conductor unblock <issue> [--force] [--no-requeue] [--project NAME]
|
|
110
113
|
omp-conductor verb <conductor_*> [--project NAME] [--arg k=v ...]
|
|
111
114
|
omp-conductor daemon [--once] [--port N] [--project NAME]
|
|
@@ -114,7 +117,8 @@ usage:
|
|
|
114
117
|
omp-conductor graph-setup [--project NAME] [--write]
|
|
115
118
|
omp-conductor brief-upgrade [--migrate|--retrofit] [--apply] [--file PATH] [--project NAME]
|
|
116
119
|
omp-conductor friction <escalation-digest|report-noise|report-surprise> --detail TEXT [--issue N] [--project NAME]
|
|
117
|
-
omp-conductor
|
|
120
|
+
omp-conductor event record --category NAME --summary TEXT --evidence REF [--occurred-at ISO] [--project NAME]
|
|
121
|
+
omp-conductor report --text TEXT [--kind material|digest] [--events IDS] [--notices IDS] [--project NAME]
|
|
118
122
|
omp-conductor decision open --question TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]
|
|
119
123
|
omp-conductor decision resolve <id> --answer TEXT [--project NAME]
|
|
120
124
|
omp-conductor decision withdraw <id> [--reason TEXT] [--project NAME]
|
|
@@ -172,8 +176,9 @@ usage:
|
|
|
172
176
|
the daemon rather than terminals, so this is the only way to watch
|
|
173
177
|
one live. Runs until Ctrl-C, or until the run has finished and its
|
|
174
178
|
transcript has stopped growing.
|
|
175
|
-
extend
|
|
176
|
-
|
|
179
|
+
extend raise a live run's turn ceiling, or set a bounded one-shot ceiling
|
|
180
|
+
after a failed, killed, orphaned or blocked run. Refuses values outside
|
|
181
|
+
configured bounds.
|
|
177
182
|
worker cooperatively pause one live worker at harness idle, then resume the
|
|
178
183
|
same session with a continuation prompt. Its wall clock is frozen
|
|
179
184
|
while parked. Distinct from fleet-level pause/resume.
|
|
@@ -195,12 +200,17 @@ usage:
|
|
|
195
200
|
strings, one per --arg (e.g. --arg prUrl=https://x --arg headSha=y).
|
|
196
201
|
A refusal exits 3. See conductor_pr_merge/conductor_label/
|
|
197
202
|
conductor_release/conductor_pr_update_branch in the brief.
|
|
203
|
+
event persist one ordinary material outcome without sending it. Category
|
|
204
|
+
is a short lowercase slug; summary and evidence name what happened
|
|
205
|
+
and where it can be verified. --occurred-at defaults to now.
|
|
198
206
|
report hand a rendered report to the daemon's durable outbox. The report is
|
|
199
207
|
persisted before anything is sent, delivered by the daemon with
|
|
200
208
|
bounded retries, and shown by status until it lands. Delivery is
|
|
201
209
|
at-least-once: a crash mid-send is retried and the retry says it may
|
|
202
210
|
be a repeat. --kind digest is accepted at most once per local day,
|
|
203
211
|
decided from the ledger rather than from what you remember sending.
|
|
212
|
+
A digest associates the comma-separated --events and --notices rows
|
|
213
|
+
atomically; omitted rows stay owed.
|
|
204
214
|
decision record, list and close the questions you have put to your operator.
|
|
205
215
|
A question that lives only in a session's context is lost to the next
|
|
206
216
|
compaction, so \`decision open\` writes it down and every tick's prompt
|
|
@@ -263,6 +273,26 @@ function flag(argv: string[], name: string): string | undefined {
|
|
|
263
273
|
return prefixed?.slice(name.length + 3);
|
|
264
274
|
}
|
|
265
275
|
|
|
276
|
+
function digestIdsFlag(argv: string[], name: "events" | "notices"): string[] {
|
|
277
|
+
const raw = flag(argv, name);
|
|
278
|
+
if (raw === undefined) {
|
|
279
|
+
if (argv.includes(`--${name}`)) {
|
|
280
|
+
process.stderr.write(`omp-conductor: report --${name} needs comma-separated row ids\n`);
|
|
281
|
+
process.exit(2);
|
|
282
|
+
}
|
|
283
|
+
return [];
|
|
284
|
+
}
|
|
285
|
+
const ids = [...new Set(raw.split(",").map((id) => id.trim()).filter((id) => id.length > 0))];
|
|
286
|
+
if (
|
|
287
|
+
ids.length === 0 ||
|
|
288
|
+
ids.some((id) => !/^(?:[0-9a-f]{12}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/.test(id))
|
|
289
|
+
) {
|
|
290
|
+
process.stderr.write(`omp-conductor: report --${name} needs comma-separated ledger row ids\n`);
|
|
291
|
+
process.exit(2);
|
|
292
|
+
}
|
|
293
|
+
return ids;
|
|
294
|
+
}
|
|
295
|
+
|
|
266
296
|
/**
|
|
267
297
|
* `--port` for the three verbs that take one. Exits 2 rather than defaulting,
|
|
268
298
|
* because silently ignoring a typo'd port would leave the operator probing an
|
|
@@ -612,14 +642,11 @@ try {
|
|
|
612
642
|
}
|
|
613
643
|
|
|
614
644
|
/**
|
|
615
|
-
* The action ledger
|
|
616
|
-
*
|
|
617
|
-
*
|
|
618
|
-
*
|
|
619
|
-
*
|
|
620
|
-
* now"; this answers "what did run 3 actually try to do", which is the
|
|
621
|
-
* question an escalation about a run asks, and it needs the whole record
|
|
622
|
-
* rather than the newest five lines of every run at once.
|
|
645
|
+
* The action ledger: mediated conductor verbs plus durable per-issue budget
|
|
646
|
+
* changes. Its own command as well as a block in `status`, because the two
|
|
647
|
+
* questions are different sizes. `status` answers "what is pending or being
|
|
648
|
+
* refused now"; this answers "what did run 3 actually try, and what budget
|
|
649
|
+
* did the operator assign", which needs history rather than only live state.
|
|
623
650
|
*/
|
|
624
651
|
case "ledger": {
|
|
625
652
|
const cfg = loadConfig();
|
|
@@ -641,18 +668,38 @@ try {
|
|
|
641
668
|
...(issue === undefined ? {} : { issue }),
|
|
642
669
|
limit,
|
|
643
670
|
});
|
|
644
|
-
|
|
671
|
+
const overrides = store.turnOverrideLedger(p.name, {
|
|
672
|
+
...(issue === undefined ? {} : { issue }),
|
|
673
|
+
limit,
|
|
674
|
+
});
|
|
675
|
+
if (entries.length === 0 && overrides.length === 0) {
|
|
645
676
|
process.stdout.write(
|
|
646
|
-
`no conductor
|
|
677
|
+
`no conductor actions recorded for ${p.name}` +
|
|
647
678
|
`${issue === undefined ? "" : ` on #${String(issue)}`}\n`,
|
|
648
679
|
);
|
|
649
680
|
break;
|
|
650
681
|
}
|
|
651
|
-
const
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
682
|
+
const blocks: string[] = [];
|
|
683
|
+
if (entries.length > 0) {
|
|
684
|
+
const refused = entries.filter((e) => e.decision === "refused").length;
|
|
685
|
+
blocks.push(
|
|
686
|
+
`${p.name} — ${entries.length} verb call(s), ${refused} refused (newest first)\n` +
|
|
687
|
+
entries.flatMap(formatVerbLedgerEntry).join("\n"),
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
if (overrides.length > 0) {
|
|
691
|
+
blocks.push(
|
|
692
|
+
`${p.name} — ${overrides.length} turn override(s) (newest first)\n` +
|
|
693
|
+
overrides
|
|
694
|
+
.map(
|
|
695
|
+
(entry) =>
|
|
696
|
+
` ${new Date(entry.setAt).toISOString()} #${entry.issue} ` +
|
|
697
|
+
`${entry.maxTurns} turns`,
|
|
698
|
+
)
|
|
699
|
+
.join("\n"),
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
process.stdout.write(`${blocks.join("\n\n")}\n`);
|
|
656
703
|
} finally {
|
|
657
704
|
store.close();
|
|
658
705
|
}
|
|
@@ -774,14 +821,30 @@ try {
|
|
|
774
821
|
);
|
|
775
822
|
const payload = (await response.json()) as {
|
|
776
823
|
error?: unknown;
|
|
824
|
+
kind?: unknown;
|
|
777
825
|
runId?: unknown;
|
|
778
826
|
maxTurns?: unknown;
|
|
827
|
+
issue?: unknown;
|
|
828
|
+
nextAttemptMaxTurns?: unknown;
|
|
829
|
+
baseMaxTurns?: unknown;
|
|
779
830
|
};
|
|
780
831
|
if (!response.ok) {
|
|
781
832
|
throw new Error(
|
|
782
833
|
typeof payload.error === "string" ? payload.error : `daemon returned HTTP ${response.status}`,
|
|
783
834
|
);
|
|
784
835
|
}
|
|
836
|
+
if (
|
|
837
|
+
payload.kind === "next-attempt" &&
|
|
838
|
+
payload.issue === issue &&
|
|
839
|
+
typeof payload.nextAttemptMaxTurns === "number" &&
|
|
840
|
+
typeof payload.baseMaxTurns === "number"
|
|
841
|
+
) {
|
|
842
|
+
process.stdout.write(
|
|
843
|
+
`#${issue} next attempt turn ceiling set to ${payload.nextAttemptMaxTurns} ` +
|
|
844
|
+
`(project base ${payload.baseMaxTurns})\n`,
|
|
845
|
+
);
|
|
846
|
+
break;
|
|
847
|
+
}
|
|
785
848
|
if (typeof payload.runId !== "string" || typeof payload.maxTurns !== "number") {
|
|
786
849
|
throw new Error("daemon returned an invalid turn-extension response");
|
|
787
850
|
}
|
|
@@ -793,13 +856,21 @@ try {
|
|
|
793
856
|
|
|
794
857
|
case "worker": {
|
|
795
858
|
const sub = argv[1];
|
|
796
|
-
if (sub !== "pause" && sub !== "resume") {
|
|
859
|
+
if (sub !== "pause" && sub !== "resume" && sub !== "stop") {
|
|
797
860
|
process.stderr.write(
|
|
798
|
-
"omp-conductor: worker needs pause or
|
|
861
|
+
"omp-conductor: worker needs pause, resume, or stop, then an issue number\n",
|
|
799
862
|
);
|
|
800
863
|
process.exit(2);
|
|
801
864
|
}
|
|
802
865
|
const issue = issueArg("worker", argv[2]);
|
|
866
|
+
const rawReason = sub === "stop" ? flag(argv, "reason") : undefined;
|
|
867
|
+
const reason = rawReason?.trim().replace(/\s+/g, " ");
|
|
868
|
+
if (sub === "stop" && (reason === undefined || reason === "" || reason.length > 500)) {
|
|
869
|
+
process.stderr.write(
|
|
870
|
+
"omp-conductor: worker stop needs --reason with 1-500 characters\n",
|
|
871
|
+
);
|
|
872
|
+
process.exit(2);
|
|
873
|
+
}
|
|
803
874
|
const project = findProject(loadConfig(), flag(argv, "project"));
|
|
804
875
|
const daemon = livingDaemon();
|
|
805
876
|
if (daemon === undefined) throw new Error("daemon is not running");
|
|
@@ -813,25 +884,63 @@ try {
|
|
|
813
884
|
{
|
|
814
885
|
method: "PUT",
|
|
815
886
|
headers: { "content-type": "application/json" },
|
|
816
|
-
body: JSON.stringify({
|
|
887
|
+
body: JSON.stringify({
|
|
888
|
+
project: project.name,
|
|
889
|
+
...(reason === undefined ? {} : { reason }),
|
|
890
|
+
}),
|
|
817
891
|
},
|
|
818
892
|
);
|
|
819
893
|
const payload = (await response.json()) as {
|
|
820
894
|
error?: unknown;
|
|
821
895
|
runId?: unknown;
|
|
822
896
|
phase?: unknown;
|
|
897
|
+
outcome?: unknown;
|
|
898
|
+
state?: unknown;
|
|
899
|
+
reason?: unknown;
|
|
900
|
+
salvageSha?: unknown;
|
|
901
|
+
salvageError?: unknown;
|
|
902
|
+
worktree?: unknown;
|
|
823
903
|
};
|
|
824
904
|
if (!response.ok) {
|
|
825
905
|
throw new Error(
|
|
826
906
|
typeof payload.error === "string" ? payload.error : `daemon returned HTTP ${response.status}`,
|
|
827
907
|
);
|
|
828
908
|
}
|
|
909
|
+
if (sub === "stop") {
|
|
910
|
+
if (
|
|
911
|
+
typeof payload.runId !== "string" ||
|
|
912
|
+
typeof payload.state !== "string" ||
|
|
913
|
+
(payload.outcome !== "stopped" && payload.outcome !== "already-terminal")
|
|
914
|
+
) {
|
|
915
|
+
throw new Error("daemon returned an invalid worker-stop response");
|
|
916
|
+
}
|
|
917
|
+
if (payload.outcome === "already-terminal") {
|
|
918
|
+
process.stdout.write(
|
|
919
|
+
`#${issue} worker already terminal: ${payload.state} (run ${payload.runId})\n`,
|
|
920
|
+
);
|
|
921
|
+
break;
|
|
922
|
+
}
|
|
923
|
+
if (typeof payload.reason !== "string") {
|
|
924
|
+
throw new Error("daemon returned an invalid worker-stop response");
|
|
925
|
+
}
|
|
926
|
+
process.stdout.write(
|
|
927
|
+
`#${issue} worker stopped (run ${payload.runId}): ${payload.reason}\n`,
|
|
928
|
+
);
|
|
929
|
+
if (typeof payload.salvageSha === "string") {
|
|
930
|
+
process.stdout.write(`work preserved: ${payload.salvageSha}\n`);
|
|
931
|
+
}
|
|
932
|
+
if (typeof payload.salvageError === "string") {
|
|
933
|
+
process.stdout.write(
|
|
934
|
+
`WIP SALVAGE FAILED: ${payload.salvageError}\n` +
|
|
935
|
+
`worktree kept: ${typeof payload.worktree === "string" ? payload.worktree : "(path unavailable)"}\n`,
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
break;
|
|
939
|
+
}
|
|
829
940
|
if (typeof payload.runId !== "string" || typeof payload.phase !== "string") {
|
|
830
941
|
throw new Error("daemon returned an invalid worker-control response");
|
|
831
942
|
}
|
|
832
|
-
process.stdout.write(
|
|
833
|
-
`#${issue} worker ${payload.phase} (run ${payload.runId})\n`,
|
|
834
|
-
);
|
|
943
|
+
process.stdout.write(`#${issue} worker ${payload.phase} (run ${payload.runId})\n`);
|
|
835
944
|
break;
|
|
836
945
|
}
|
|
837
946
|
|
|
@@ -938,6 +1047,58 @@ try {
|
|
|
938
1047
|
break;
|
|
939
1048
|
}
|
|
940
1049
|
|
|
1050
|
+
case "event": {
|
|
1051
|
+
if (argv[1] !== "record") {
|
|
1052
|
+
process.stderr.write("omp-conductor: event needs the record subcommand\n");
|
|
1053
|
+
process.exit(2);
|
|
1054
|
+
}
|
|
1055
|
+
const category = flag(argv, "category");
|
|
1056
|
+
const summary = flag(argv, "summary")?.replace(/\s+/g, " ").trim();
|
|
1057
|
+
const evidence = flag(argv, "evidence")?.replace(/\s+/g, " ").trim();
|
|
1058
|
+
if (
|
|
1059
|
+
category === undefined ||
|
|
1060
|
+
!/^[a-z0-9][a-z0-9-]{0,31}$/.test(category) ||
|
|
1061
|
+
summary === undefined ||
|
|
1062
|
+
summary.length === 0 ||
|
|
1063
|
+
summary.length > 240 ||
|
|
1064
|
+
summary.startsWith("--") ||
|
|
1065
|
+
evidence === undefined ||
|
|
1066
|
+
evidence.length === 0 ||
|
|
1067
|
+
evidence.length > 500 ||
|
|
1068
|
+
evidence.startsWith("--")
|
|
1069
|
+
) {
|
|
1070
|
+
process.stderr.write(
|
|
1071
|
+
"omp-conductor: event record needs --category with a lowercase slug, --summary (1-240 chars), and --evidence (1-500 chars)\n",
|
|
1072
|
+
);
|
|
1073
|
+
process.exit(2);
|
|
1074
|
+
}
|
|
1075
|
+
const rawOccurredAt = flag(argv, "occurred-at");
|
|
1076
|
+
const recordedAt = Date.now();
|
|
1077
|
+
const occurredAt = rawOccurredAt === undefined ? recordedAt : Date.parse(rawOccurredAt);
|
|
1078
|
+
if (!Number.isFinite(occurredAt)) {
|
|
1079
|
+
process.stderr.write("omp-conductor: event record --occurred-at needs an ISO timestamp\n");
|
|
1080
|
+
process.exit(2);
|
|
1081
|
+
}
|
|
1082
|
+
const project = findProject(loadConfig(), flag(argv, "project"));
|
|
1083
|
+
const store = openStore(dbPath());
|
|
1084
|
+
try {
|
|
1085
|
+
const event = store.recordMaterialEvent({
|
|
1086
|
+
project: project.name,
|
|
1087
|
+
category,
|
|
1088
|
+
summary,
|
|
1089
|
+
evidence,
|
|
1090
|
+
occurredAt,
|
|
1091
|
+
recordedAt,
|
|
1092
|
+
});
|
|
1093
|
+
process.stdout.write(
|
|
1094
|
+
`event ${event.id} recorded for ${project.name} (${event.category}, ${new Date(event.occurredAt).toISOString()}) — nothing sent\n`,
|
|
1095
|
+
);
|
|
1096
|
+
} finally {
|
|
1097
|
+
store.close();
|
|
1098
|
+
}
|
|
1099
|
+
break;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
941
1102
|
/**
|
|
942
1103
|
* The handover point. Authorship stays with the model; from here the daemon
|
|
943
1104
|
* owns delivery, so "I sent the report" stops being a claim the model makes
|
|
@@ -962,6 +1123,12 @@ try {
|
|
|
962
1123
|
process.exit(2);
|
|
963
1124
|
}
|
|
964
1125
|
const kind = rawKind as ReportKind;
|
|
1126
|
+
if (kind !== "digest" && (argv.includes("--events") || argv.includes("--notices"))) {
|
|
1127
|
+
process.stderr.write(
|
|
1128
|
+
"omp-conductor: report --events and --notices are valid only with --kind digest\n",
|
|
1129
|
+
);
|
|
1130
|
+
process.exit(2);
|
|
1131
|
+
}
|
|
965
1132
|
const project = findProject(loadConfig(), flag(argv, "project"));
|
|
966
1133
|
const store = openStore(dbPath());
|
|
967
1134
|
try {
|
|
@@ -970,8 +1137,8 @@ try {
|
|
|
970
1137
|
// policy defers is refused here rather than silently turning into a
|
|
971
1138
|
// page, or a digest going out off-schedule.
|
|
972
1139
|
const policy = project.reporting;
|
|
1140
|
+
const digestPolicy = policy?.digest ?? { cadence: "per-tick" };
|
|
973
1141
|
if (kind === "digest") {
|
|
974
|
-
const digestPolicy = policy?.digest ?? { cadence: "per-tick" };
|
|
975
1142
|
if (digestPolicy.cadence === "none") {
|
|
976
1143
|
process.stderr.write(`omp-conductor: report: digest cadence is "none" for this project\n`);
|
|
977
1144
|
process.exit(2);
|
|
@@ -989,32 +1156,54 @@ try {
|
|
|
989
1156
|
}
|
|
990
1157
|
}
|
|
991
1158
|
if (kind === "material") {
|
|
992
|
-
const
|
|
993
|
-
if (
|
|
1159
|
+
const disposition = interruptDisposition(policy, "material", at);
|
|
1160
|
+
if (disposition === "digest") {
|
|
994
1161
|
process.stderr.write(
|
|
995
1162
|
"omp-conductor: report: material updates are digest-only under this reporting policy; fold this into the next digest (--kind digest)\n",
|
|
996
1163
|
);
|
|
997
1164
|
process.exit(2);
|
|
998
1165
|
}
|
|
1166
|
+
if (disposition === "availability") {
|
|
1167
|
+
const noticeId = randomUUID();
|
|
1168
|
+
store.addHeldNotice({
|
|
1169
|
+
id: noticeId,
|
|
1170
|
+
project: project.name,
|
|
1171
|
+
category: "material",
|
|
1172
|
+
summary: body.split("\n", 1)[0]!.slice(0, 240),
|
|
1173
|
+
detail: body,
|
|
1174
|
+
createdAt: at,
|
|
1175
|
+
releaseOnAvailable: true,
|
|
1176
|
+
});
|
|
1177
|
+
process.stdout.write(
|
|
1178
|
+
`held notice ${noticeId} queued for ${project.name} (material; quiet hours)\n` +
|
|
1179
|
+
"the daemon will include it in the next digest or working-hours catch-up\n",
|
|
1180
|
+
);
|
|
1181
|
+
break;
|
|
1182
|
+
}
|
|
999
1183
|
}
|
|
1000
|
-
const
|
|
1184
|
+
const draft = {
|
|
1001
1185
|
project: project.name,
|
|
1002
1186
|
kind,
|
|
1003
1187
|
body,
|
|
1004
|
-
// Only
|
|
1005
|
-
//
|
|
1006
|
-
...(kind === "digest"
|
|
1007
|
-
? { dedupeKey: digestDedupeKey(at,
|
|
1188
|
+
// Only a daily digest is at-most-once. Per-tick digests and material
|
|
1189
|
+
// reports describe new outcomes each time, so they carry no daily key.
|
|
1190
|
+
...(kind === "digest" && digestPolicy.cadence === "daily"
|
|
1191
|
+
? { dedupeKey: digestDedupeKey(at, digestPolicy.timezone) }
|
|
1008
1192
|
: {}),
|
|
1009
1193
|
at,
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1194
|
+
};
|
|
1195
|
+
const { report, deduped } =
|
|
1196
|
+
kind === "digest"
|
|
1197
|
+
? store.enqueueDigestReport(
|
|
1198
|
+
draft,
|
|
1199
|
+
digestIdsFlag(argv, "events"),
|
|
1200
|
+
digestIdsFlag(argv, "notices"),
|
|
1201
|
+
)
|
|
1202
|
+
: store.enqueueReport(draft);
|
|
1014
1203
|
process.stdout.write(
|
|
1015
1204
|
deduped
|
|
1016
1205
|
? `today's digest was already handed over as report ${report.id} (${report.state}) — nothing queued\n` +
|
|
1017
|
-
"the ledger decides this, not your memory of the last tick;
|
|
1206
|
+
"the ledger decides this, not your memory of the last tick; newly named rows remain owed for a later digest\n"
|
|
1018
1207
|
: `report ${report.id} queued for ${project.name} (${kind})\n` +
|
|
1019
1208
|
"the daemon owns delivery from here; omp-conductor status shows it until it lands\n",
|
|
1020
1209
|
);
|
package/src/config.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
REPORT_SCOPES,
|
|
38
38
|
INTERRUPT_CATEGORIES,
|
|
39
39
|
DIGEST_CADENCES,
|
|
40
|
+
WEEKDAYS,
|
|
40
41
|
type Caps,
|
|
41
42
|
type ConductorConfig,
|
|
42
43
|
type DigestCadence,
|
|
@@ -49,6 +50,8 @@ import {
|
|
|
49
50
|
type ReleaseRequirement,
|
|
50
51
|
type ReportScope,
|
|
51
52
|
type ReportingPolicy,
|
|
53
|
+
type Weekday,
|
|
54
|
+
type WeeklyAvailability,
|
|
52
55
|
type RepoTarget,
|
|
53
56
|
type ResolvedGrants,
|
|
54
57
|
} from "./types.ts";
|
|
@@ -72,6 +75,7 @@ const CAP_KEYS = Object.keys(DEFAULT_CAPS) as (keyof Caps)[];
|
|
|
72
75
|
const REPORT_SCOPE_LIST = quoteList(REPORT_SCOPES);
|
|
73
76
|
const INTERRUPT_CATEGORY_LIST = quoteList(INTERRUPT_CATEGORIES);
|
|
74
77
|
const DIGEST_CADENCE_LIST = quoteList(DIGEST_CADENCES);
|
|
78
|
+
const WEEKDAY_LIST = quoteList(WEEKDAYS);
|
|
75
79
|
const AUTHORITY_HOLDER_LIST = quoteList(AUTHORITY_HOLDERS);
|
|
76
80
|
const ORCHESTRATOR_MODE_LIST = quoteList(ORCHESTRATOR_MODES);
|
|
77
81
|
const RELEASE_SHAPE_LIST = quoteList(RELEASE_SHAPES);
|
|
@@ -227,12 +231,16 @@ export function writeConfigRaw(text: string): void {
|
|
|
227
231
|
*/
|
|
228
232
|
export function resolveCaps(p: ProjectConfig, defaults: Caps): Caps {
|
|
229
233
|
const o: Partial<Caps> = p.caps ?? {};
|
|
234
|
+
const workerMaxTurns = o.workerMaxTurns ?? defaults.workerMaxTurns;
|
|
230
235
|
return {
|
|
231
236
|
maxConcurrentWorkers: o.maxConcurrentWorkers ?? defaults.maxConcurrentWorkers,
|
|
232
237
|
maxConcurrentWorkersPerRepo: o.maxConcurrentWorkersPerRepo ?? defaults.maxConcurrentWorkersPerRepo,
|
|
233
238
|
dailySpendUsd: o.dailySpendUsd !== undefined ? o.dailySpendUsd : defaults.dailySpendUsd,
|
|
234
239
|
planUsage: o.planUsage !== undefined ? o.planUsage : defaults.planUsage,
|
|
235
|
-
workerMaxTurns
|
|
240
|
+
workerMaxTurns,
|
|
241
|
+
workerMaxTurnsCeiling:
|
|
242
|
+
o.workerMaxTurnsCeiling ??
|
|
243
|
+
(o.workerMaxTurns === undefined ? defaults.workerMaxTurnsCeiling : workerMaxTurns * 2),
|
|
236
244
|
workerWallClockMs: o.workerWallClockMs ?? defaults.workerWallClockMs,
|
|
237
245
|
maxAttemptsPerIssue: o.maxAttemptsPerIssue ?? defaults.maxAttemptsPerIssue,
|
|
238
246
|
maxContinuationsPerIssue:
|
|
@@ -366,9 +374,13 @@ function validate(parsed: unknown, path: string): ConductorConfig {
|
|
|
366
374
|
);
|
|
367
375
|
}
|
|
368
376
|
|
|
377
|
+
const configuredDefaults = coerceCaps(root["defaults"], `"defaults"`, problems, legacyCaps);
|
|
378
|
+
const defaultWorkerMaxTurns = configuredDefaults.workerMaxTurns ?? DEFAULT_CAPS.workerMaxTurns;
|
|
369
379
|
const defaults: Caps = {
|
|
370
380
|
...DEFAULT_CAPS,
|
|
371
|
-
...
|
|
381
|
+
...configuredDefaults,
|
|
382
|
+
workerMaxTurnsCeiling:
|
|
383
|
+
configuredDefaults.workerMaxTurnsCeiling ?? defaultWorkerMaxTurns * 2,
|
|
372
384
|
};
|
|
373
385
|
|
|
374
386
|
const rawProjects = root["projects"];
|
|
@@ -536,15 +548,17 @@ function normalizeReporting(parsed: unknown, label: string, problems: string[]):
|
|
|
536
548
|
const keys = Object.keys(raw);
|
|
537
549
|
// `scopePreset` is written by a fully-normalised policy (the setup wizard
|
|
538
550
|
// saves presets materialised); `scope` is the legacy form. Both are known.
|
|
539
|
-
const known = ["scope", "interruptOn", "digest", "scopePreset"];
|
|
551
|
+
const known = ["scope", "interruptOn", "digest", "availability", "scopePreset"];
|
|
540
552
|
const unknownKeys = keys.filter((k) => !known.includes(k));
|
|
541
553
|
if (unknownKeys.length > 0) {
|
|
542
554
|
problems.push(`${label}: reporting has unknown key(s): ${unknownKeys.join(", ")}`);
|
|
543
555
|
}
|
|
544
556
|
const hasScope = raw["scope"] !== undefined;
|
|
545
|
-
const hasExplicit = keys.includes("interruptOn") || keys.includes("digest");
|
|
557
|
+
const hasExplicit = keys.includes("interruptOn") || keys.includes("digest") || keys.includes("availability");
|
|
546
558
|
if (hasScope && hasExplicit) {
|
|
547
|
-
problems.push(
|
|
559
|
+
problems.push(
|
|
560
|
+
`${label}: reporting.scope is a preset — remove it when configuring interruptOn/digest/availability explicitly`,
|
|
561
|
+
);
|
|
548
562
|
return defaultReporting();
|
|
549
563
|
}
|
|
550
564
|
if (hasScope || (!hasExplicit && keys.length === 0)) {
|
|
@@ -573,9 +587,23 @@ function normalizeReporting(parsed: unknown, label: string, problems: string[]):
|
|
|
573
587
|
? (storedPreset as ReportScope)
|
|
574
588
|
: undefined;
|
|
575
589
|
|
|
590
|
+
const interruptOn = normalizeInterruptOn(raw["interruptOn"], label, problems);
|
|
591
|
+
const digest = normalizeDigest(raw["digest"], label, problems);
|
|
592
|
+
const availability = normalizeAvailability(raw["availability"], label, problems);
|
|
593
|
+
// One setup answer supplies both clocks. Hand-written configs may omit the
|
|
594
|
+
// digest copy; the availability zone then becomes its daily clock too.
|
|
595
|
+
if (availability !== undefined && digest.cadence === "daily") {
|
|
596
|
+
if (digest.timezone === undefined) digest.timezone = availability.timezone;
|
|
597
|
+
else if (digest.timezone !== availability.timezone) {
|
|
598
|
+
problems.push(
|
|
599
|
+
`${label}: reporting.digest.timezone must match reporting.availability.timezone`,
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
576
603
|
return {
|
|
577
|
-
interruptOn
|
|
578
|
-
digest
|
|
604
|
+
interruptOn,
|
|
605
|
+
digest,
|
|
606
|
+
...(availability === undefined ? {} : { availability }),
|
|
579
607
|
...(scopePreset === undefined ? {} : { scopePreset }),
|
|
580
608
|
};
|
|
581
609
|
}
|
|
@@ -657,6 +685,94 @@ function normalizeDigest(parsed: unknown, label: string, problems: string[]): Re
|
|
|
657
685
|
return digest;
|
|
658
686
|
}
|
|
659
687
|
|
|
688
|
+
function normalizeAvailability(
|
|
689
|
+
parsed: unknown,
|
|
690
|
+
label: string,
|
|
691
|
+
problems: string[],
|
|
692
|
+
): WeeklyAvailability | undefined {
|
|
693
|
+
if (parsed === undefined) return undefined;
|
|
694
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
695
|
+
problems.push(`${label}: reporting.availability must be an object`);
|
|
696
|
+
return undefined;
|
|
697
|
+
}
|
|
698
|
+
const raw = parsed as Raw;
|
|
699
|
+
const unknownKeys = Object.keys(raw).filter(
|
|
700
|
+
(key) => !["timezone", "days", "start", "end", "bypass"].includes(key),
|
|
701
|
+
);
|
|
702
|
+
if (unknownKeys.length > 0) {
|
|
703
|
+
problems.push(`${label}: reporting.availability has unknown key(s): ${unknownKeys.join(", ")}`);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
let timezone: string | undefined;
|
|
707
|
+
if (typeof raw["timezone"] !== "string" || raw["timezone"].trim() === "") {
|
|
708
|
+
problems.push(`${label}: reporting.availability.timezone must be a known IANA timezone`);
|
|
709
|
+
} else {
|
|
710
|
+
try {
|
|
711
|
+
new Intl.DateTimeFormat("en-GB", { timeZone: raw["timezone"] });
|
|
712
|
+
timezone = raw["timezone"];
|
|
713
|
+
} catch {
|
|
714
|
+
problems.push(`${label}: reporting.availability.timezone is not a known IANA timezone`);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const days: Weekday[] = [];
|
|
719
|
+
if (!Array.isArray(raw["days"]) || raw["days"].length === 0) {
|
|
720
|
+
problems.push(`${label}: reporting.availability.days must be a non-empty array of ${WEEKDAY_LIST}`);
|
|
721
|
+
} else {
|
|
722
|
+
for (const item of raw["days"]) {
|
|
723
|
+
if (typeof item !== "string" || !(WEEKDAYS as readonly string[]).includes(item)) {
|
|
724
|
+
problems.push(
|
|
725
|
+
`${label}: reporting.availability.days has unknown day ${JSON.stringify(item)} — one of ${WEEKDAY_LIST}`,
|
|
726
|
+
);
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
const day = item as Weekday;
|
|
730
|
+
if (!days.includes(day)) days.push(day);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const start = raw["start"];
|
|
735
|
+
const end = raw["end"];
|
|
736
|
+
if (typeof start !== "string" || !DIGEST_AT.test(start)) {
|
|
737
|
+
problems.push(`${label}: reporting.availability.start must be a 24h HH:MM time`);
|
|
738
|
+
}
|
|
739
|
+
if (typeof end !== "string" || !DIGEST_AT.test(end)) {
|
|
740
|
+
problems.push(`${label}: reporting.availability.end must be a 24h HH:MM time`);
|
|
741
|
+
}
|
|
742
|
+
if (typeof start === "string" && typeof end === "string" && start === end) {
|
|
743
|
+
problems.push(`${label}: reporting.availability.start and end must differ`);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const bypass: InterruptCategory[] = [];
|
|
747
|
+
if (!Array.isArray(raw["bypass"])) {
|
|
748
|
+
problems.push(
|
|
749
|
+
`${label}: reporting.availability.bypass must be an array of ${INTERRUPT_CATEGORY_LIST} (empty means none)`,
|
|
750
|
+
);
|
|
751
|
+
} else {
|
|
752
|
+
for (const item of raw["bypass"]) {
|
|
753
|
+
if (typeof item !== "string" || !(INTERRUPT_CATEGORIES as readonly string[]).includes(item)) {
|
|
754
|
+
problems.push(
|
|
755
|
+
`${label}: reporting.availability.bypass has unknown category ${JSON.stringify(item)} — one of ${INTERRUPT_CATEGORY_LIST}`,
|
|
756
|
+
);
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
const category = item as InterruptCategory;
|
|
760
|
+
if (!bypass.includes(category)) bypass.push(category);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
return timezone === undefined ||
|
|
765
|
+
days.length === 0 ||
|
|
766
|
+
typeof start !== "string" ||
|
|
767
|
+
!DIGEST_AT.test(start) ||
|
|
768
|
+
typeof end !== "string" ||
|
|
769
|
+
!DIGEST_AT.test(end) ||
|
|
770
|
+
start === end ||
|
|
771
|
+
!Array.isArray(raw["bypass"])
|
|
772
|
+
? undefined
|
|
773
|
+
: { timezone, days, start, end, bypass };
|
|
774
|
+
}
|
|
775
|
+
|
|
660
776
|
/**
|
|
661
777
|
* Who triages escalations, and how they are delivered when nobody answers.
|
|
662
778
|
*
|