omp-conductor 0.10.0 → 0.13.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 +73 -19
- package/package.json +1 -1
- package/src/board.ts +125 -11
- package/src/briefs/orchestrator.md +50 -4
- package/src/briefs/policy.md +4 -1
- package/src/chain-check.ts +157 -0
- package/src/cli.ts +188 -19
- package/src/config.ts +195 -15
- package/src/daemon.ts +1017 -119
- package/src/diff-flags.ts +48 -3
- package/src/digest-schedule.ts +59 -0
- package/src/escalate.ts +17 -0
- package/src/failure-class.ts +31 -4
- package/src/fleet.ts +8 -3
- package/src/gitops.ts +49 -0
- package/src/omp.ts +44 -9
- package/src/orchestrator-tick.ts +94 -9
- package/src/orchestrator.ts +3 -2
- package/src/plugin.ts +46 -4
- package/src/release-policy.ts +66 -6
- package/src/reports.ts +5 -6
- package/src/session-host.ts +23 -6
- package/src/setup.ts +43 -10
- package/src/store.ts +263 -31
- package/src/tracker/github.ts +261 -56
- package/src/types.ts +251 -32
- package/src/verbs/actions.ts +127 -15
- package/src/verbs/protocol.ts +7 -6
- package/src/verbs/server.ts +144 -13
- package/src/worker.ts +183 -17
- package/src/worktree.ts +5 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ordered-migration-chain guard (#227).
|
|
3
|
+
*
|
|
4
|
+
* A project that declares `routing.repos.<repo>.migrations.dir` gets its
|
|
5
|
+
* merges checked against one invariant: after the merge, the base branch's
|
|
6
|
+
* `revision`/`down_revision` graph has exactly one head. The check runs on the
|
|
7
|
+
* base tip at merge time, against the PR's diff — a stale parent, a reused
|
|
8
|
+
* revision id, or a deleted migration is refused before the merge, because a
|
|
9
|
+
* wrong merge here is silent production schema drift.
|
|
10
|
+
*
|
|
11
|
+
* Alembic-convention only (`revision = "<id>"` / `down_revision = <expr>` in
|
|
12
|
+
* `*.py`); a repo without the config key is never touched. Pure by
|
|
13
|
+
* construction: every fact arrives as strings, and the module reads no files.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { PrDiffFile } from "./types.ts";
|
|
17
|
+
|
|
18
|
+
/** One migration file's identity, as the chain check reasons about it. */
|
|
19
|
+
export interface ChainEntry {
|
|
20
|
+
/** Repo-relative path of the file that declares the revision. */
|
|
21
|
+
path: string;
|
|
22
|
+
/** The revision id this file declares. */
|
|
23
|
+
id: string;
|
|
24
|
+
/** Parent revision ids; empty for a root. */
|
|
25
|
+
parents: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const REVISION_LINE = /^\s*revision(?::[^=]*)?\s*=\s*["']([^"']+)["']/m;
|
|
29
|
+
const DOWN_REVISION_LINE = /^\s*down_revision(?::[^=]*)?\s*=\s*(.*)$/m;
|
|
30
|
+
const QUOTED = /["']([^"']+)["']/g;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Parse one migration file's source into its chain identity, or `undefined`
|
|
34
|
+
* when it declares no `revision` (not a chain file; skip it).
|
|
35
|
+
*
|
|
36
|
+
* `down_revision` may be `"abc"`, `None`, or a tuple `('a', 'b')` (a merge
|
|
37
|
+
* migration) — the parents are every quoted string on the right-hand side, so
|
|
38
|
+
* `None`/empty reads as a root without special-casing syntax.
|
|
39
|
+
*/
|
|
40
|
+
export function parseChainSource(path: string, source: string): ChainEntry | undefined {
|
|
41
|
+
const id = REVISION_LINE.exec(source)?.[1];
|
|
42
|
+
if (id === undefined) return undefined;
|
|
43
|
+
const parents: string[] = [];
|
|
44
|
+
const rhs = DOWN_REVISION_LINE.exec(source)?.[1] ?? "";
|
|
45
|
+
for (const m of rhs.matchAll(QUOTED)) {
|
|
46
|
+
const parent = m[1];
|
|
47
|
+
if (parent !== undefined && !parents.includes(parent)) parents.push(parent);
|
|
48
|
+
}
|
|
49
|
+
return { path, id, parents };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Post-image source of one diff file: the added lines, rejoined as a file. */
|
|
53
|
+
function postImageSource(file: PrDiffFile): string {
|
|
54
|
+
const hunks = file.hunks ?? "";
|
|
55
|
+
const lines: string[] = [];
|
|
56
|
+
for (const line of hunks.split("\n")) {
|
|
57
|
+
if (line.startsWith("+")) lines.push(line.slice(1));
|
|
58
|
+
}
|
|
59
|
+
return lines.join("\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Whether one diff entry lives inside the chain directory. */
|
|
63
|
+
function inChainDir(dir: string, path: string): boolean {
|
|
64
|
+
return path.startsWith(`${dir}/`) && path.endsWith(".py");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What the PR does to the chain: entries added, entries changed (post-image
|
|
69
|
+
* used — a modified file's base entry stands when its added lines declare no
|
|
70
|
+
* revision), and chain files deleted.
|
|
71
|
+
*/
|
|
72
|
+
export function chainEntriesFromDiff(
|
|
73
|
+
files: readonly PrDiffFile[],
|
|
74
|
+
dir: string,
|
|
75
|
+
): { added: ChainEntry[]; changed: ChainEntry[]; deleted: string[] } {
|
|
76
|
+
const added: ChainEntry[] = [];
|
|
77
|
+
const changed: ChainEntry[] = [];
|
|
78
|
+
const deleted: string[] = [];
|
|
79
|
+
for (const file of files) {
|
|
80
|
+
if (!inChainDir(dir, file.path)) continue;
|
|
81
|
+
if (file.status === "removed") {
|
|
82
|
+
deleted.push(file.path);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const entry = parseChainSource(file.path, postImageSource(file));
|
|
86
|
+
if (entry === undefined) continue;
|
|
87
|
+
if (file.status === "added") added.push(entry);
|
|
88
|
+
else changed.push(entry); // modified, or renamed into the chain directory
|
|
89
|
+
}
|
|
90
|
+
return { added, changed, deleted };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ChainViolationInput {
|
|
94
|
+
base: ChainEntry[];
|
|
95
|
+
added: ChainEntry[];
|
|
96
|
+
changed: ChainEntry[];
|
|
97
|
+
deleted: string[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Refuse a merge that would corrupt the ordered chain, or return [] to pass.
|
|
102
|
+
*
|
|
103
|
+
* A combined graph that no PR touches is never judged: a broken base is not
|
|
104
|
+
* this PR's problem.
|
|
105
|
+
*/
|
|
106
|
+
export function chainViolations(input: ChainViolationInput): string[] {
|
|
107
|
+
const touched = input.added.length + input.changed.length + input.deleted.length;
|
|
108
|
+
if (touched === 0) return [];
|
|
109
|
+
|
|
110
|
+
const violations: string[] = [];
|
|
111
|
+
|
|
112
|
+
// 1. The one deletion nothing can justify: a published migration may not
|
|
113
|
+
// disappear from the base branch.
|
|
114
|
+
for (const path of input.deleted) {
|
|
115
|
+
if (input.base.some((entry) => entry.path === path)) {
|
|
116
|
+
violations.push(`deletes ${path}, which exists on the base branch — a published migration may never be deleted`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 2. Reusing an id that already exists on the base at a DIFFERENT path makes
|
|
121
|
+
// two files claim one revision; the graph stops being an order.
|
|
122
|
+
const baseByPath = new Map(input.base.map((entry) => [entry.path, entry]));
|
|
123
|
+
for (const entry of [...input.added, ...input.changed]) {
|
|
124
|
+
const owner = input.base.find((b) => b.id === entry.id);
|
|
125
|
+
if (owner !== undefined && owner.path !== entry.path) {
|
|
126
|
+
violations.push(`reuses revision id ${entry.id}, already declared by ${owner.path} on the base branch`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 3. Head coherence: the combined graph — base, minus deleted files, minus
|
|
131
|
+
// changed files' base entries (replaced by their post-image), plus added
|
|
132
|
+
// entries — must have exactly one head. Two heads means a stale parent or
|
|
133
|
+
// a fork nobody repaired; one head after a repair merge is the goal. A
|
|
134
|
+
// duplicate id inside the PR's own entries is incoherent too.
|
|
135
|
+
const combined = new Map(baseByPath);
|
|
136
|
+
for (const path of input.deleted) combined.delete(path);
|
|
137
|
+
for (const entry of input.changed) combined.delete(entry.path);
|
|
138
|
+
for (const entry of [...input.changed, ...input.added]) combined.set(entry.path, entry);
|
|
139
|
+
|
|
140
|
+
const ids = [...combined.values()].map((entry) => entry.id);
|
|
141
|
+
const duplicateIds = ids.filter((id, index) => ids.indexOf(id) !== index);
|
|
142
|
+
if (duplicateIds.length > 0) {
|
|
143
|
+
violations.push(
|
|
144
|
+
`the migration graph would declare duplicate revision id${duplicateIds.length > 1 ? "s" : ""} ${[...new Set(duplicateIds)].join(", ")} after merge`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const referenced = new Set([...combined.values()].flatMap((entry) => entry.parents));
|
|
149
|
+
const heads = ids.filter((id, index) => ids.indexOf(id) === index && !referenced.has(id));
|
|
150
|
+
if (heads.length !== 1) {
|
|
151
|
+
violations.push(
|
|
152
|
+
`the migration graph would have ${heads.length} heads after merge (${heads.join(", ") || "none"}); parent(s) must be the current tip`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return violations;
|
|
157
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
startHerdrFleet,
|
|
37
37
|
} from "./fleet.ts";
|
|
38
38
|
import { formatGraphSetup, graphRepos, writeGraphSetup, type GraphSetupWrite } from "./graph.ts";
|
|
39
|
+
import { readBaseChain } from "./gitops.ts";
|
|
39
40
|
import {
|
|
40
41
|
clearRecord,
|
|
41
42
|
DEFAULT_PORT,
|
|
@@ -48,6 +49,7 @@ import {
|
|
|
48
49
|
} from "./lifecycle.ts";
|
|
49
50
|
import { STALL_MARKER_FILE } from "./orchestrator-tick.ts";
|
|
50
51
|
import { digestDedupeKey } from "./reports.ts";
|
|
52
|
+
import { digestDue } from "./digest-schedule.ts";
|
|
51
53
|
import {
|
|
52
54
|
briefPathForProject,
|
|
53
55
|
policyPathForProject,
|
|
@@ -61,7 +63,7 @@ import { formatVerbLedgerEntry } from "./verbs/ledger.ts";
|
|
|
61
63
|
import { makeTracker } from "./tracker/github.ts";
|
|
62
64
|
import { githubVerbActions } from "./verbs/actions.ts";
|
|
63
65
|
import { handleVerbCall, type VerbChannel } from "./verbs/server.ts";
|
|
64
|
-
import { REPORT_KINDS, VERB_NAMES } from "./types.ts";
|
|
66
|
+
import { REPORT_KINDS, DEFAULT_REPORT_POLICY, VERB_NAMES } from "./types.ts";
|
|
65
67
|
import type { ProjectConfig, ReportKind } from "./types.ts";
|
|
66
68
|
import { formatUnblock, unblockIssue } from "./unblock.ts";
|
|
67
69
|
import { DEFAULT_DEPS, drainAndRestart, upgradeConductor, type UpgradeDeps } from "./upgrade.ts";
|
|
@@ -102,6 +104,9 @@ usage:
|
|
|
102
104
|
omp-conductor release-pane [--project NAME]
|
|
103
105
|
omp-conductor tail <issue> [--project NAME]
|
|
104
106
|
omp-conductor extend <issue> --turns N [--project NAME]
|
|
107
|
+
omp-conductor worker pause <issue> [--project NAME]
|
|
108
|
+
omp-conductor worker resume <issue> [--project NAME]
|
|
109
|
+
omp-conductor worker stop <issue> --reason TEXT [--project NAME]
|
|
105
110
|
omp-conductor unblock <issue> [--force] [--no-requeue] [--project NAME]
|
|
106
111
|
omp-conductor verb <conductor_*> [--project NAME] [--arg k=v ...]
|
|
107
112
|
omp-conductor daemon [--once] [--port N] [--project NAME]
|
|
@@ -168,8 +173,12 @@ usage:
|
|
|
168
173
|
the daemon rather than terminals, so this is the only way to watch
|
|
169
174
|
one live. Runs until Ctrl-C, or until the run has finished and its
|
|
170
175
|
transcript has stopped growing.
|
|
171
|
-
extend
|
|
172
|
-
|
|
176
|
+
extend raise a live run's turn ceiling, or set a bounded one-shot ceiling
|
|
177
|
+
after a failed, killed, orphaned or blocked run. Refuses values outside
|
|
178
|
+
configured bounds.
|
|
179
|
+
worker cooperatively pause one live worker at harness idle, then resume the
|
|
180
|
+
same session with a continuation prompt. Its wall clock is frozen
|
|
181
|
+
while parked. Distinct from fleet-level pause/resume.
|
|
173
182
|
unblock clear <issue>'s blocked and failed labels so the next tick can claim
|
|
174
183
|
it again — the supported way back for an escalation you answered,
|
|
175
184
|
and why the brief's "never hand-edit a state label" rule can stay
|
|
@@ -605,14 +614,11 @@ try {
|
|
|
605
614
|
}
|
|
606
615
|
|
|
607
616
|
/**
|
|
608
|
-
* The action ledger
|
|
609
|
-
*
|
|
610
|
-
*
|
|
611
|
-
*
|
|
612
|
-
*
|
|
613
|
-
* now"; this answers "what did run 3 actually try to do", which is the
|
|
614
|
-
* question an escalation about a run asks, and it needs the whole record
|
|
615
|
-
* rather than the newest five lines of every run at once.
|
|
617
|
+
* The action ledger: mediated conductor verbs plus durable per-issue budget
|
|
618
|
+
* changes. Its own command as well as a block in `status`, because the two
|
|
619
|
+
* questions are different sizes. `status` answers "what is pending or being
|
|
620
|
+
* refused now"; this answers "what did run 3 actually try, and what budget
|
|
621
|
+
* did the operator assign", which needs history rather than only live state.
|
|
616
622
|
*/
|
|
617
623
|
case "ledger": {
|
|
618
624
|
const cfg = loadConfig();
|
|
@@ -634,18 +640,38 @@ try {
|
|
|
634
640
|
...(issue === undefined ? {} : { issue }),
|
|
635
641
|
limit,
|
|
636
642
|
});
|
|
637
|
-
|
|
643
|
+
const overrides = store.turnOverrideLedger(p.name, {
|
|
644
|
+
...(issue === undefined ? {} : { issue }),
|
|
645
|
+
limit,
|
|
646
|
+
});
|
|
647
|
+
if (entries.length === 0 && overrides.length === 0) {
|
|
638
648
|
process.stdout.write(
|
|
639
|
-
`no conductor
|
|
649
|
+
`no conductor actions recorded for ${p.name}` +
|
|
640
650
|
`${issue === undefined ? "" : ` on #${String(issue)}`}\n`,
|
|
641
651
|
);
|
|
642
652
|
break;
|
|
643
653
|
}
|
|
644
|
-
const
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
654
|
+
const blocks: string[] = [];
|
|
655
|
+
if (entries.length > 0) {
|
|
656
|
+
const refused = entries.filter((e) => e.decision === "refused").length;
|
|
657
|
+
blocks.push(
|
|
658
|
+
`${p.name} — ${entries.length} verb call(s), ${refused} refused (newest first)\n` +
|
|
659
|
+
entries.flatMap(formatVerbLedgerEntry).join("\n"),
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
if (overrides.length > 0) {
|
|
663
|
+
blocks.push(
|
|
664
|
+
`${p.name} — ${overrides.length} turn override(s) (newest first)\n` +
|
|
665
|
+
overrides
|
|
666
|
+
.map(
|
|
667
|
+
(entry) =>
|
|
668
|
+
` ${new Date(entry.setAt).toISOString()} #${entry.issue} ` +
|
|
669
|
+
`${entry.maxTurns} turns`,
|
|
670
|
+
)
|
|
671
|
+
.join("\n"),
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
process.stdout.write(`${blocks.join("\n\n")}\n`);
|
|
649
675
|
} finally {
|
|
650
676
|
store.close();
|
|
651
677
|
}
|
|
@@ -767,14 +793,30 @@ try {
|
|
|
767
793
|
);
|
|
768
794
|
const payload = (await response.json()) as {
|
|
769
795
|
error?: unknown;
|
|
796
|
+
kind?: unknown;
|
|
770
797
|
runId?: unknown;
|
|
771
798
|
maxTurns?: unknown;
|
|
799
|
+
issue?: unknown;
|
|
800
|
+
nextAttemptMaxTurns?: unknown;
|
|
801
|
+
baseMaxTurns?: unknown;
|
|
772
802
|
};
|
|
773
803
|
if (!response.ok) {
|
|
774
804
|
throw new Error(
|
|
775
805
|
typeof payload.error === "string" ? payload.error : `daemon returned HTTP ${response.status}`,
|
|
776
806
|
);
|
|
777
807
|
}
|
|
808
|
+
if (
|
|
809
|
+
payload.kind === "next-attempt" &&
|
|
810
|
+
payload.issue === issue &&
|
|
811
|
+
typeof payload.nextAttemptMaxTurns === "number" &&
|
|
812
|
+
typeof payload.baseMaxTurns === "number"
|
|
813
|
+
) {
|
|
814
|
+
process.stdout.write(
|
|
815
|
+
`#${issue} next attempt turn ceiling set to ${payload.nextAttemptMaxTurns} ` +
|
|
816
|
+
`(project base ${payload.baseMaxTurns})\n`,
|
|
817
|
+
);
|
|
818
|
+
break;
|
|
819
|
+
}
|
|
778
820
|
if (typeof payload.runId !== "string" || typeof payload.maxTurns !== "number") {
|
|
779
821
|
throw new Error("daemon returned an invalid turn-extension response");
|
|
780
822
|
}
|
|
@@ -784,6 +826,96 @@ try {
|
|
|
784
826
|
break;
|
|
785
827
|
}
|
|
786
828
|
|
|
829
|
+
case "worker": {
|
|
830
|
+
const sub = argv[1];
|
|
831
|
+
if (sub !== "pause" && sub !== "resume" && sub !== "stop") {
|
|
832
|
+
process.stderr.write(
|
|
833
|
+
"omp-conductor: worker needs pause, resume, or stop, then an issue number\n",
|
|
834
|
+
);
|
|
835
|
+
process.exit(2);
|
|
836
|
+
}
|
|
837
|
+
const issue = issueArg("worker", argv[2]);
|
|
838
|
+
const rawReason = sub === "stop" ? flag(argv, "reason") : undefined;
|
|
839
|
+
const reason = rawReason?.trim().replace(/\s+/g, " ");
|
|
840
|
+
if (sub === "stop" && (reason === undefined || reason === "" || reason.length > 500)) {
|
|
841
|
+
process.stderr.write(
|
|
842
|
+
"omp-conductor: worker stop needs --reason with 1-500 characters\n",
|
|
843
|
+
);
|
|
844
|
+
process.exit(2);
|
|
845
|
+
}
|
|
846
|
+
const project = findProject(loadConfig(), flag(argv, "project"));
|
|
847
|
+
const daemon = livingDaemon();
|
|
848
|
+
if (daemon === undefined) throw new Error("daemon is not running");
|
|
849
|
+
if (daemon.project !== undefined && daemon.project !== project.name) {
|
|
850
|
+
throw new Error(
|
|
851
|
+
`daemon serves project "${daemon.project}", not requested project "${project.name}"`,
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
const response = await fetch(
|
|
855
|
+
`http://127.0.0.1:${daemon.port}/runs/${issue}/${sub}`,
|
|
856
|
+
{
|
|
857
|
+
method: "PUT",
|
|
858
|
+
headers: { "content-type": "application/json" },
|
|
859
|
+
body: JSON.stringify({
|
|
860
|
+
project: project.name,
|
|
861
|
+
...(reason === undefined ? {} : { reason }),
|
|
862
|
+
}),
|
|
863
|
+
},
|
|
864
|
+
);
|
|
865
|
+
const payload = (await response.json()) as {
|
|
866
|
+
error?: unknown;
|
|
867
|
+
runId?: unknown;
|
|
868
|
+
phase?: unknown;
|
|
869
|
+
outcome?: unknown;
|
|
870
|
+
state?: unknown;
|
|
871
|
+
reason?: unknown;
|
|
872
|
+
salvageSha?: unknown;
|
|
873
|
+
salvageError?: unknown;
|
|
874
|
+
worktree?: unknown;
|
|
875
|
+
};
|
|
876
|
+
if (!response.ok) {
|
|
877
|
+
throw new Error(
|
|
878
|
+
typeof payload.error === "string" ? payload.error : `daemon returned HTTP ${response.status}`,
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
if (sub === "stop") {
|
|
882
|
+
if (
|
|
883
|
+
typeof payload.runId !== "string" ||
|
|
884
|
+
typeof payload.state !== "string" ||
|
|
885
|
+
(payload.outcome !== "stopped" && payload.outcome !== "already-terminal")
|
|
886
|
+
) {
|
|
887
|
+
throw new Error("daemon returned an invalid worker-stop response");
|
|
888
|
+
}
|
|
889
|
+
if (payload.outcome === "already-terminal") {
|
|
890
|
+
process.stdout.write(
|
|
891
|
+
`#${issue} worker already terminal: ${payload.state} (run ${payload.runId})\n`,
|
|
892
|
+
);
|
|
893
|
+
break;
|
|
894
|
+
}
|
|
895
|
+
if (typeof payload.reason !== "string") {
|
|
896
|
+
throw new Error("daemon returned an invalid worker-stop response");
|
|
897
|
+
}
|
|
898
|
+
process.stdout.write(
|
|
899
|
+
`#${issue} worker stopped (run ${payload.runId}): ${payload.reason}\n`,
|
|
900
|
+
);
|
|
901
|
+
if (typeof payload.salvageSha === "string") {
|
|
902
|
+
process.stdout.write(`work preserved: ${payload.salvageSha}\n`);
|
|
903
|
+
}
|
|
904
|
+
if (typeof payload.salvageError === "string") {
|
|
905
|
+
process.stdout.write(
|
|
906
|
+
`WIP SALVAGE FAILED: ${payload.salvageError}\n` +
|
|
907
|
+
`worktree kept: ${typeof payload.worktree === "string" ? payload.worktree : "(path unavailable)"}\n`,
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
break;
|
|
911
|
+
}
|
|
912
|
+
if (typeof payload.runId !== "string" || typeof payload.phase !== "string") {
|
|
913
|
+
throw new Error("daemon returned an invalid worker-control response");
|
|
914
|
+
}
|
|
915
|
+
process.stdout.write(`#${issue} worker ${payload.phase} (run ${payload.runId})\n`);
|
|
916
|
+
break;
|
|
917
|
+
}
|
|
918
|
+
|
|
787
919
|
case "unblock": {
|
|
788
920
|
const issue = issueArg("unblock", argv[1]);
|
|
789
921
|
const cfg = loadConfig();
|
|
@@ -873,6 +1005,7 @@ try {
|
|
|
873
1005
|
pausedAt,
|
|
874
1006
|
log: (m) => process.stderr.write(`${m}\n`),
|
|
875
1007
|
now: () => Date.now(),
|
|
1008
|
+
chain: { readBaseChain },
|
|
876
1009
|
},
|
|
877
1010
|
channel,
|
|
878
1011
|
{ verb: name, args },
|
|
@@ -914,15 +1047,51 @@ try {
|
|
|
914
1047
|
const store = openStore(dbPath());
|
|
915
1048
|
try {
|
|
916
1049
|
const at = Date.now();
|
|
1050
|
+
// #229: the policy decides what may interrupt the phone. A kind the
|
|
1051
|
+
// policy defers is refused here rather than silently turning into a
|
|
1052
|
+
// page, or a digest going out off-schedule.
|
|
1053
|
+
const policy = project.reporting;
|
|
1054
|
+
if (kind === "digest") {
|
|
1055
|
+
const digestPolicy = policy?.digest ?? { cadence: "per-tick" };
|
|
1056
|
+
if (digestPolicy.cadence === "none") {
|
|
1057
|
+
process.stderr.write(`omp-conductor: report: digest cadence is "none" for this project\n`);
|
|
1058
|
+
process.exit(2);
|
|
1059
|
+
}
|
|
1060
|
+
if (digestPolicy.cadence === "daily" && digestPolicy.at !== undefined) {
|
|
1061
|
+
const lastKey = store.lastDigestDedupeKey(project.name);
|
|
1062
|
+
const lastDay = lastKey === undefined ? undefined : lastKey.slice("digest:".length);
|
|
1063
|
+
if (!digestDue(policy ?? DEFAULT_REPORT_POLICY, lastDay, at)) {
|
|
1064
|
+
process.stderr.write(
|
|
1065
|
+
`omp-conductor: report: the daily digest is not due until ${digestPolicy.at}` +
|
|
1066
|
+
`${digestPolicy.timezone === undefined ? "" : ` ${digestPolicy.timezone}`}\n`,
|
|
1067
|
+
);
|
|
1068
|
+
process.exit(2);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
if (kind === "material") {
|
|
1073
|
+
const interruptOn = policy?.interruptOn;
|
|
1074
|
+
if (interruptOn !== undefined && !interruptOn.includes("material")) {
|
|
1075
|
+
process.stderr.write(
|
|
1076
|
+
"omp-conductor: report: material updates are digest-only under this reporting policy; fold this into the next digest (--kind digest)\n",
|
|
1077
|
+
);
|
|
1078
|
+
process.exit(2);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
917
1081
|
const { report, deduped } = store.enqueueReport({
|
|
918
1082
|
project: project.name,
|
|
919
1083
|
kind,
|
|
920
1084
|
body,
|
|
921
1085
|
// Only the digest is at-most-once. A material report describes one
|
|
922
1086
|
// event as it happens, and two of those in a day are two events.
|
|
923
|
-
...(kind === "digest"
|
|
1087
|
+
...(kind === "digest"
|
|
1088
|
+
? { dedupeKey: digestDedupeKey(at, project.reporting?.digest?.timezone) }
|
|
1089
|
+
: {}),
|
|
924
1090
|
at,
|
|
925
1091
|
});
|
|
1092
|
+
// The held notices an accepted digest re-surfaces are now owed by it,
|
|
1093
|
+
// whatever the model goes on to write.
|
|
1094
|
+
if (kind === "digest") store.markNoticesDigested(project.name, at);
|
|
926
1095
|
process.stdout.write(
|
|
927
1096
|
deduped
|
|
928
1097
|
? `today's digest was already handed over as report ${report.id} (${report.state}) — nothing queued\n` +
|