omp-conductor 0.4.2 → 0.4.3
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 +189 -50
- package/package.json +1 -1
- package/src/board.ts +6 -1
- package/src/brief-upgrade.ts +42 -105
- package/src/briefs/orchestrator.md +51 -13
- package/src/briefs/policy.md +6 -2
- package/src/cli.ts +147 -30
- package/src/config.ts +23 -52
- package/src/confinement.ts +22 -472
- package/src/daemon.ts +344 -3
- package/src/decisions.ts +164 -0
- package/src/failure-class.ts +172 -0
- package/src/fleet.ts +192 -51
- package/src/omp.ts +8 -54
- package/src/orchestrator-tick.ts +33 -0
- package/src/plugin.ts +9 -20
- package/src/session-host.ts +10 -22
- package/src/setup.ts +22 -4
- package/src/store.ts +220 -4
- package/src/tracker/github.ts +167 -1
- package/src/types.ts +179 -13
- package/src/upgrade.ts +27 -3
package/src/daemon.ts
CHANGED
|
@@ -37,6 +37,8 @@ import { createReportOutbox, formatOpenReports } from "./reports.ts";
|
|
|
37
37
|
import { recordReleaseBlock } from "./release-policy.ts";
|
|
38
38
|
import { branchName, route } from "./routing.ts";
|
|
39
39
|
import type { Routed, UnroutableReason } from "./routing.ts";
|
|
40
|
+
import { evaluateDecisionConditions, probeNpmVersion } from "./decisions.ts";
|
|
41
|
+
import { classifyRun, type ClassifyFacts } from "./failure-class.ts";
|
|
40
42
|
import { dbPath, openStore } from "./store.ts";
|
|
41
43
|
import { makeTracker } from "./tracker/github.ts";
|
|
42
44
|
import { RELEASE_SHAPES } from "./types.ts";
|
|
@@ -53,6 +55,8 @@ import type {
|
|
|
53
55
|
RepoTarget,
|
|
54
56
|
ReportRecord,
|
|
55
57
|
ResolvedGrants,
|
|
58
|
+
FailureClass,
|
|
59
|
+
RecoveryAction,
|
|
56
60
|
RunRecord,
|
|
57
61
|
RunState,
|
|
58
62
|
SettlementFlag,
|
|
@@ -2191,6 +2195,46 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
|
|
|
2191
2195
|
// through the pool without blocking this five-minute tick.
|
|
2192
2196
|
await settlePushedGreen(d);
|
|
2193
2197
|
|
|
2198
|
+
// Immediately after settlement and before any routing, so a class is on the
|
|
2199
|
+
// row before the next dispatch decision reads its budgets (#132). Above the
|
|
2200
|
+
// pause gate deliberately: classification and label reconciliation are
|
|
2201
|
+
// maintenance, and the four phantom `agent:failed` issues this exists to clear
|
|
2202
|
+
// are exactly as misleading on a parked fleet as on a busy one.
|
|
2203
|
+
//
|
|
2204
|
+
// Each is guarded on its own: a tracker that fails mid-classification must not
|
|
2205
|
+
// stop the label reconcile, and neither may stop the tick.
|
|
2206
|
+
try {
|
|
2207
|
+
await classifyAndRecover(d);
|
|
2208
|
+
} catch (err) {
|
|
2209
|
+
log(`classification sweep failed: ${errText(err)}`);
|
|
2210
|
+
}
|
|
2211
|
+
try {
|
|
2212
|
+
await reconcileStaleLabels(d);
|
|
2213
|
+
} catch (err) {
|
|
2214
|
+
log(`label reconcile failed: ${errText(err)}`);
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
// Ledger maintenance, above the pause gate for the same reason the stall watch
|
|
2218
|
+
// is: a paused fleet still owes its operator the questions it asked, and a
|
|
2219
|
+
// condition that came true while dispatch was parked is exactly the thing the
|
|
2220
|
+
// orchestrator has to see on its next tick.
|
|
2221
|
+
//
|
|
2222
|
+
// Expiry is synchronous (one UPDATE); condition evaluation is fire-and-forget
|
|
2223
|
+
// because it shells out to `gh` and `npm`, and a registry that hangs must cost
|
|
2224
|
+
// one unevaluated condition rather than the tick.
|
|
2225
|
+
for (const expired of d.store.expireDueDecisions(d.project.name, Date.now())) {
|
|
2226
|
+
log(`decision ${expired.id} expired unanswered after seven days: ${expired.question}`);
|
|
2227
|
+
}
|
|
2228
|
+
void evaluateDecisionConditions(d.store, d.project.name, d.tracker, probeNpmVersion, Date.now)
|
|
2229
|
+
.then((met) => {
|
|
2230
|
+
for (const decision of met) {
|
|
2231
|
+
log(`decision ${decision.id} condition met (${decision.condition ?? "?"}) — surfacing on the next tick`);
|
|
2232
|
+
}
|
|
2233
|
+
})
|
|
2234
|
+
.catch((err: unknown) => {
|
|
2235
|
+
log(`decision condition pass failed: ${errText(err)}`);
|
|
2236
|
+
});
|
|
2237
|
+
|
|
2194
2238
|
// A paused fleet claims nothing. Checked first so pausing takes effect on the
|
|
2195
2239
|
// next tick without signalling the process.
|
|
2196
2240
|
if (isPaused()) return;
|
|
@@ -2746,6 +2790,299 @@ export function prepareConductor(): void {
|
|
|
2746
2790
|
setPaused(true);
|
|
2747
2791
|
}
|
|
2748
2792
|
|
|
2793
|
+
/** Bounded per tick: each row costs tracker calls to gather facts for. */
|
|
2794
|
+
const CLASSIFY_BATCH = 20;
|
|
2795
|
+
|
|
2796
|
+
/** Tool calls quoted as evidence for a run that spun to its turn cap. */
|
|
2797
|
+
const SPIN_EVIDENCE_CALLS = 10;
|
|
2798
|
+
|
|
2799
|
+
/**
|
|
2800
|
+
* The last few tool names a transcript recorded, newest last.
|
|
2801
|
+
*
|
|
2802
|
+
* `turn-cap-spinning` escalates rather than requeueing, and the acceptance
|
|
2803
|
+
* criterion is that the escalation carries evidence of what the worker was doing
|
|
2804
|
+
* when it hit the cap — otherwise the orchestrator opens the transcript and
|
|
2805
|
+
* re-derives it, which is the manual triage this whole sweep removes.
|
|
2806
|
+
*/
|
|
2807
|
+
export function lastToolCalls(sessionFile: string | undefined, limit = SPIN_EVIDENCE_CALLS): string[] {
|
|
2808
|
+
if (sessionFile === undefined) return [];
|
|
2809
|
+
let text: string;
|
|
2810
|
+
try {
|
|
2811
|
+
text = readFileSync(sessionFile, "utf8");
|
|
2812
|
+
} catch {
|
|
2813
|
+
return [];
|
|
2814
|
+
}
|
|
2815
|
+
const names: string[] = [];
|
|
2816
|
+
for (const line of text.split("\n")) {
|
|
2817
|
+
if (line.length === 0) continue;
|
|
2818
|
+
let row: unknown;
|
|
2819
|
+
try {
|
|
2820
|
+
row = JSON.parse(line) as unknown;
|
|
2821
|
+
} catch {
|
|
2822
|
+
continue;
|
|
2823
|
+
}
|
|
2824
|
+
if (row === null || typeof row !== "object") continue;
|
|
2825
|
+
const rec = row as { readonly [key: string]: unknown };
|
|
2826
|
+
// Both shapes the harness has written: a top-level tool event, and a tool
|
|
2827
|
+
// block inside an assistant message.
|
|
2828
|
+
const direct = rec["toolName"];
|
|
2829
|
+
if (typeof direct === "string") {
|
|
2830
|
+
names.push(direct);
|
|
2831
|
+
continue;
|
|
2832
|
+
}
|
|
2833
|
+
const message = rec["message"];
|
|
2834
|
+
if (message === null || typeof message !== "object") continue;
|
|
2835
|
+
const content = (message as { readonly [key: string]: unknown })["content"];
|
|
2836
|
+
if (!Array.isArray(content)) continue;
|
|
2837
|
+
for (const part of content) {
|
|
2838
|
+
if (part === null || typeof part !== "object") continue;
|
|
2839
|
+
const p = part as { readonly [key: string]: unknown };
|
|
2840
|
+
if (p["type"] !== "tool_use") continue;
|
|
2841
|
+
const name = p["name"];
|
|
2842
|
+
if (typeof name === "string") names.push(name);
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2845
|
+
return names.slice(-limit);
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
/**
|
|
2849
|
+
* Classify every unclassified terminal run, persist the verdict, and perform the
|
|
2850
|
+
* one recovery its class names (#132).
|
|
2851
|
+
*
|
|
2852
|
+
* Half this fleet's spend produced no merged PR, and every one of those runs
|
|
2853
|
+
* ended at a human who re-derived the same triage by hand and then threw the
|
|
2854
|
+
* conclusion away. The mechanical classes — a cancelled runner, a kill from a
|
|
2855
|
+
* daemon restart, a green PR whose base moved, a row whose PR had already merged
|
|
2856
|
+
* — need no judgement at all; the genuinely human ones are worth a person's
|
|
2857
|
+
* attention only if they arrive with their evidence already gathered.
|
|
2858
|
+
*
|
|
2859
|
+
* Facts are fetched per row and only the ones that row needs: a `killed` row
|
|
2860
|
+
* costs nothing, a `failed` row with a PR costs a state read and a check read.
|
|
2861
|
+
* A `pushed-green` row that classifies to nothing is left completely untouched —
|
|
2862
|
+
* it is healthy, and writing a class onto it would take it out of this sweep for
|
|
2863
|
+
* good.
|
|
2864
|
+
*/
|
|
2865
|
+
export async function classifyAndRecover(d: Deps): Promise<void> {
|
|
2866
|
+
const { project, tracker, store } = d;
|
|
2867
|
+
for (const run of store.runsNeedingClassification(project.name, CLASSIFY_BATCH)) {
|
|
2868
|
+
const facts: ClassifyFacts = {};
|
|
2869
|
+
try {
|
|
2870
|
+
if (run.prUrl !== undefined) {
|
|
2871
|
+
const pr = await tracker.prState(run.prUrl);
|
|
2872
|
+
if (pr !== undefined) facts.pr = pr;
|
|
2873
|
+
if (run.state === "pushed-green" && facts.pr === "open") {
|
|
2874
|
+
facts.mergeable = await tracker.mergeable(run.prUrl);
|
|
2875
|
+
}
|
|
2876
|
+
if (run.state === "failed" && facts.pr === "open") {
|
|
2877
|
+
facts.checks = await tracker.checkConclusions(run.prUrl);
|
|
2878
|
+
}
|
|
2879
|
+
}
|
|
2880
|
+
} catch (err) {
|
|
2881
|
+
// Per row, like every other sweep here: one unreachable PR must not stop
|
|
2882
|
+
// the rest from being classified. The next tick asks again for free.
|
|
2883
|
+
log(`#${run.issue} not classified: fact gathering failed (${errText(err)}) — retrying next tick`);
|
|
2884
|
+
continue;
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
const { cls, recovery, evidence } = classifyRun(run, facts);
|
|
2888
|
+
|
|
2889
|
+
// A healthy green PR is not a failure of any class. Leaving the row
|
|
2890
|
+
// unclassified is what keeps it eligible for the sweep on the tick where its
|
|
2891
|
+
// base does move under it.
|
|
2892
|
+
if (run.state === "pushed-green" && cls === "unknown") continue;
|
|
2893
|
+
|
|
2894
|
+
store.updateRun(run.id, { failureClass: cls, recoveryAction: recovery });
|
|
2895
|
+
log(`#${run.issue} classified ${cls} → ${recovery}: ${evidence}`);
|
|
2896
|
+
await recoverRun(d, run, cls, recovery, evidence);
|
|
2897
|
+
}
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
/** Performs the one action a class names. Never chooses one of its own. */
|
|
2901
|
+
async function recoverRun(
|
|
2902
|
+
d: Deps,
|
|
2903
|
+
run: RunRecord,
|
|
2904
|
+
cls: FailureClass,
|
|
2905
|
+
recovery: RecoveryAction,
|
|
2906
|
+
evidence: string,
|
|
2907
|
+
): Promise<void> {
|
|
2908
|
+
const { project, tracker, store } = d;
|
|
2909
|
+
const inProgress = project.stateLabels.inProgress;
|
|
2910
|
+
|
|
2911
|
+
if (recovery === "settle") {
|
|
2912
|
+
// Label before row, copied from `settlePushedGreen` where the order is the
|
|
2913
|
+
// whole safety argument: writing the terminal state first would put this row
|
|
2914
|
+
// beyond every later tick, and a tracker that then failed on the label would
|
|
2915
|
+
// strand `agent:in-progress` with nothing left to retry it (#18).
|
|
2916
|
+
if (!(await releaseInProgress(d, run.issue, `PR merged: ${evidence}`))) return;
|
|
2917
|
+
store.updateRun(run.id, { state: "merged", endedAt: Date.now(), recoveredAt: Date.now() });
|
|
2918
|
+
log(`#${run.issue} settled from ${cls}: ${evidence}`);
|
|
2919
|
+
return;
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
if (recovery === "continue") {
|
|
2923
|
+
// The branch is retained and its PR is open, so #50's continuation guard
|
|
2924
|
+
// admits it: the next tick reattaches the branch and briefs a rebase.
|
|
2925
|
+
store.updateRun(run.id, {
|
|
2926
|
+
state: "killed",
|
|
2927
|
+
lastError:
|
|
2928
|
+
"merge-conflict: base moved under a green PR — rebase, regenerate recorded artifacts, push without force",
|
|
2929
|
+
recoveredAt: Date.now(),
|
|
2930
|
+
});
|
|
2931
|
+
if (!(await swapToQueue(d, run.issue, inProgress))) return;
|
|
2932
|
+
log(`#${run.issue} requeued for a rebase continuation: ${evidence}`);
|
|
2933
|
+
return;
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
if (recovery === "requeue") {
|
|
2937
|
+
// Only when the tracker still shows this issue as ours to hand back. An
|
|
2938
|
+
// issue that is closed, or has no state label, was resolved by another route
|
|
2939
|
+
// and requeueing it would dispatch work nobody asked for.
|
|
2940
|
+
const state = await tracker.issueState(run.issue).catch(() => undefined);
|
|
2941
|
+
if (state !== "open") {
|
|
2942
|
+
log(`#${run.issue} not requeued from ${cls}: issue is ${state ?? "unreadable"}`);
|
|
2943
|
+
return;
|
|
2944
|
+
}
|
|
2945
|
+
const label = cls === "orphan-clean" ? inProgress : project.stateLabels.failed;
|
|
2946
|
+
if (!(await swapToQueue(d, run.issue, label))) return;
|
|
2947
|
+
store.updateRun(run.id, { recoveredAt: Date.now() });
|
|
2948
|
+
log(`#${run.issue} requeued from ${cls}: ${evidence}`);
|
|
2949
|
+
return;
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
if (recovery === "rerun-checks") {
|
|
2953
|
+
if (run.prUrl === undefined) return;
|
|
2954
|
+
try {
|
|
2955
|
+
await tracker.rerunFailedChecks(run.prUrl);
|
|
2956
|
+
} catch (err) {
|
|
2957
|
+
log(`#${run.issue} check re-run failed (${errText(err)}) — retrying next tick`);
|
|
2958
|
+
return;
|
|
2959
|
+
}
|
|
2960
|
+
// Back to pending rather than green: the existing settle sweep re-verifies
|
|
2961
|
+
// it against the recorded head on a later tick, so nothing here has to guess
|
|
2962
|
+
// whether the re-run passed.
|
|
2963
|
+
store.updateRun(run.id, { state: "pushed-pending", lastError: undefined, recoveredAt: Date.now() });
|
|
2964
|
+
log(`#${run.issue} re-ran infrastructure checks: ${evidence}`);
|
|
2965
|
+
return;
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
if (recovery === "escalate") {
|
|
2969
|
+
const detail = [evidence];
|
|
2970
|
+
if (cls === "turn-cap-spinning") {
|
|
2971
|
+
const calls = lastToolCalls(run.sessionFile);
|
|
2972
|
+
detail.push(
|
|
2973
|
+
calls.length === 0
|
|
2974
|
+
? "transcript unreadable — no tool calls could be recovered"
|
|
2975
|
+
: `Last ${calls.length} tool calls: ${calls.join(" → ")}`,
|
|
2976
|
+
);
|
|
2977
|
+
}
|
|
2978
|
+
if (run.lastError !== undefined && cls !== "question") detail.push(run.lastError);
|
|
2979
|
+
detail.push(`Session: ${run.sessionFile ?? "(no transcript)"}`);
|
|
2980
|
+
// The class and the run are in the summary, which is what the notifications
|
|
2981
|
+
// ledger dedupes on — so one class escalates once per run rather than every
|
|
2982
|
+
// five minutes.
|
|
2983
|
+
await safeEscalate(d, {
|
|
2984
|
+
tier: 1,
|
|
2985
|
+
project: project.name,
|
|
2986
|
+
issue: run.issue,
|
|
2987
|
+
runId: run.id,
|
|
2988
|
+
summary: `[${cls}] #${run.issue} attempt ${run.attempt}: ${evidence}`,
|
|
2989
|
+
detail: detail.join("\n"),
|
|
2990
|
+
});
|
|
2991
|
+
// The hand-off IS the recovery for these classes: there is nothing else this
|
|
2992
|
+
// package can do, and leaving the row unrecovered would re-escalate forever.
|
|
2993
|
+
store.updateRun(run.id, { recoveredAt: Date.now() });
|
|
2994
|
+
return;
|
|
2995
|
+
}
|
|
2996
|
+
|
|
2997
|
+
// `hold` (orphan-dirty) and `none`: recorded, nothing performed. The existing
|
|
2998
|
+
// unsalvaged-WIP admission hold already fails dispatch closed until an
|
|
2999
|
+
// operator acknowledges the tree, which is the only safe move when the
|
|
3000
|
+
// worktree holds the only copy of real work.
|
|
3001
|
+
}
|
|
3002
|
+
|
|
3003
|
+
/**
|
|
3004
|
+
* Swap a state label for the queue label, in that order.
|
|
3005
|
+
*
|
|
3006
|
+
* Both writes go through the same Tracker port the dispatcher claimed with, so
|
|
3007
|
+
* orphan detection stays trustworthy. Returns false when either half failed, so
|
|
3008
|
+
* the caller leaves the row for the next tick rather than recording a recovery
|
|
3009
|
+
* that did not happen.
|
|
3010
|
+
*/
|
|
3011
|
+
async function swapToQueue(
|
|
3012
|
+
d: Pick<Deps, "project" | "tracker">,
|
|
3013
|
+
issue: number,
|
|
3014
|
+
label: string,
|
|
3015
|
+
): Promise<boolean> {
|
|
3016
|
+
try {
|
|
3017
|
+
await d.tracker.removeLabel(issue, label);
|
|
3018
|
+
await d.tracker.addLabel(issue, d.project.queueLabel);
|
|
3019
|
+
return true;
|
|
3020
|
+
} catch (err) {
|
|
3021
|
+
log(`#${issue} could not be requeued (${errText(err)}) — retrying next tick`);
|
|
3022
|
+
return false;
|
|
3023
|
+
}
|
|
3024
|
+
}
|
|
3025
|
+
|
|
3026
|
+
/** Bounded listing per state label, so one reconcile cannot walk a whole repo. */
|
|
3027
|
+
const RECONCILE_LIMIT = 50;
|
|
3028
|
+
|
|
3029
|
+
/**
|
|
3030
|
+
* Clear state labels from issues that no longer need them (#132's `superseded`).
|
|
3031
|
+
*
|
|
3032
|
+
* Two structural signals, both cheap and both observed on this fleet: an issue
|
|
3033
|
+
* that is closed but still carries `agent:*`, and an open issue carrying
|
|
3034
|
+
* `failed` whose sub-issues have all closed. On 2026-08-09 four issues (#307,
|
|
3035
|
+
* #297, #140, #82) carried `agent:failed` while every one of them was already
|
|
3036
|
+
* complete — the label was residue of a turns-cap kill from two days earlier,
|
|
3037
|
+
* and nothing in the loop ever revisited it. The board counted four phantom
|
|
3038
|
+
* failures while the genuinely stuck issues were invisible.
|
|
3039
|
+
*
|
|
3040
|
+
* Positive evidence only. A tracker that cannot list answers empty, and an empty
|
|
3041
|
+
* answer removes nothing: a reconcile that guessed would strip the interlock
|
|
3042
|
+
* that keeps two workers off one issue.
|
|
3043
|
+
*/
|
|
3044
|
+
export async function reconcileStaleLabels(d: Deps): Promise<void> {
|
|
3045
|
+
const { project, tracker, store } = d;
|
|
3046
|
+
const labels = [project.stateLabels.failed, project.stateLabels.blocked, project.stateLabels.inProgress];
|
|
3047
|
+
|
|
3048
|
+
for (const label of labels) {
|
|
3049
|
+
const carrying = await tracker.listLabeled(label, RECONCILE_LIMIT).catch(() => []);
|
|
3050
|
+
for (const issue of carrying) {
|
|
3051
|
+
if (issue.state === "closed") {
|
|
3052
|
+
// Never retain an `agent:*` label on a closed issue: the work is done by
|
|
3053
|
+
// some route, and the label only makes the board lie about it.
|
|
3054
|
+
try {
|
|
3055
|
+
await tracker.removeLabel(issue.number, label);
|
|
3056
|
+
log(`#${issue.number} reconciled: closed issue no longer carries ${label}`);
|
|
3057
|
+
} catch (err) {
|
|
3058
|
+
log(`#${issue.number} could not drop ${label} (${errText(err)}) — retrying next tick`);
|
|
3059
|
+
}
|
|
3060
|
+
continue;
|
|
3061
|
+
}
|
|
3062
|
+
|
|
3063
|
+
if (label !== project.stateLabels.failed) continue;
|
|
3064
|
+
const children = await tracker.childrenOf(issue.number).catch(() => []);
|
|
3065
|
+
if (children.length === 0 || children.some((c) => c.state !== "closed")) continue;
|
|
3066
|
+
|
|
3067
|
+
const key = `${project.name}:superseded:${issue.number}`;
|
|
3068
|
+
if (store.wasNotified(key)) continue;
|
|
3069
|
+
const list = children.map((c) => `#${c.number}`).join(", ");
|
|
3070
|
+
try {
|
|
3071
|
+
await tracker.removeLabel(issue.number, label);
|
|
3072
|
+
await tracker.comment(
|
|
3073
|
+
issue.number,
|
|
3074
|
+
`superseded: all sub-issues closed (${list}) — this label was stale; propose closing if the ` +
|
|
3075
|
+
`acceptance criteria are met on the default branch.`,
|
|
3076
|
+
);
|
|
3077
|
+
store.markNotified(key);
|
|
3078
|
+
log(`#${issue.number} reconciled: superseded by ${list}`);
|
|
3079
|
+
} catch (err) {
|
|
3080
|
+
log(`#${issue.number} could not be reconciled (${errText(err)}) — retrying next tick`);
|
|
3081
|
+
}
|
|
3082
|
+
}
|
|
3083
|
+
}
|
|
3084
|
+
}
|
|
3085
|
+
|
|
2749
3086
|
/**
|
|
2750
3087
|
* Settles `claimed`/`running` rows left by a dead daemon process and, before
|
|
2751
3088
|
* marking each one `orphaned`, salvages any dirty worktree.
|
|
@@ -2957,9 +3294,13 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
2957
3294
|
// Its own principal, distinct from every worker slot. The orchestrator
|
|
2958
3295
|
// reads the state directory and its briefs and must have no read or
|
|
2959
3296
|
// write access to any run checkout — so `workspaceRoot` and the mirror
|
|
2960
|
-
// are denied outright here
|
|
2961
|
-
//
|
|
2962
|
-
//
|
|
3297
|
+
// are denied outright here. This is the ONLY mechanical file gate an
|
|
3298
|
+
// orchestrator gets: the tool-layer jail was deleted in 0.4.3 (#143)
|
|
3299
|
+
// because it could only be installed in sessions this daemon spawns, and
|
|
3300
|
+
// the principal refuses the syscall `bash` would make anyway, which the
|
|
3301
|
+
// jail never did. It is also conditional, and honestly so: on a host with
|
|
3302
|
+
// no isolating mechanism there is no principal and no gate, which is what
|
|
3303
|
+
// `status` reports as `unprotected`.
|
|
2963
3304
|
// Same rule as a worker's, for the same reason: under `uid-pool` the
|
|
2964
3305
|
// orchestrator is its own uid, so every tree it must reach lives outside
|
|
2965
3306
|
// the 0700 private state directory. Its principal is in `conductor-runs`
|
package/src/decisions.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pending-decision ledger's grammar, evaluation and digest (#136).
|
|
3
|
+
*
|
|
4
|
+
* A question the orchestrator sends its operator used to live in exactly one
|
|
5
|
+
* place: the model's context. A compaction, a restart, or a tick that ran long
|
|
6
|
+
* lost the question *and* the fact that one was owed — after which the session
|
|
7
|
+
* either asked again (the operator answers twice) or dropped it silently (the
|
|
8
|
+
* decision never lands, and nothing says a decision is outstanding).
|
|
9
|
+
*
|
|
10
|
+
* The store holds the rows. This module holds the two things around them that
|
|
11
|
+
* are not storage: what a machine-checkable precondition looks like, and how an
|
|
12
|
+
* open row is rendered into a tick prompt.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { DecisionRecord, Store, Tracker } from "./types.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A precondition whose truth this package can check on its own.
|
|
19
|
+
*
|
|
20
|
+
* Exactly three kinds, deliberately. Each one is a question the tracker or npm
|
|
21
|
+
* already answers, so the row moves from "parked" to "act on this" without a
|
|
22
|
+
* human re-reading it. Anything richer — a label appearing, a workflow going
|
|
23
|
+
* green — is a follow-on issue rather than a grammar nobody validated.
|
|
24
|
+
*/
|
|
25
|
+
export type DecisionCondition =
|
|
26
|
+
| { kind: "pr-merged"; url: string }
|
|
27
|
+
| { kind: "issue-closed"; issue: number }
|
|
28
|
+
| { kind: "npm-version"; spec: string };
|
|
29
|
+
|
|
30
|
+
/** `pkg@version`, including a scoped package (`@scope/pkg@1.2.3`). */
|
|
31
|
+
const NPM_SPEC = /^(@[^/@\s]+\/)?[^@\s]+@[^\s]+$/;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse a raw condition, or `undefined` when it is not one of the three forms.
|
|
35
|
+
*
|
|
36
|
+
* Parsing on read rather than on write is what keeps a row this build does not
|
|
37
|
+
* understand *listable*: the raw string is stored verbatim, so a future grammar
|
|
38
|
+
* does not make an old row unloadable, and an unparseable one simply never
|
|
39
|
+
* reports itself met.
|
|
40
|
+
*/
|
|
41
|
+
export function parseCondition(raw: string): DecisionCondition | undefined {
|
|
42
|
+
const text = raw.trim();
|
|
43
|
+
const at = text.indexOf(":");
|
|
44
|
+
if (at <= 0) return undefined;
|
|
45
|
+
const kind = text.slice(0, at);
|
|
46
|
+
const rest = text.slice(at + 1).trim();
|
|
47
|
+
if (rest.length === 0) return undefined;
|
|
48
|
+
|
|
49
|
+
if (kind === "pr-merged") {
|
|
50
|
+
return rest.startsWith("https://") ? { kind: "pr-merged", url: rest } : undefined;
|
|
51
|
+
}
|
|
52
|
+
if (kind === "issue-closed") {
|
|
53
|
+
if (!/^\d+$/.test(rest)) return undefined;
|
|
54
|
+
const issue = Number.parseInt(rest, 10);
|
|
55
|
+
return issue > 0 ? { kind: "issue-closed", issue } : undefined;
|
|
56
|
+
}
|
|
57
|
+
if (kind === "npm-version") {
|
|
58
|
+
return NPM_SPEC.test(rest) ? { kind: "npm-version", spec: rest } : undefined;
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The three accepted forms, for a refusal that can be acted on in one turn. */
|
|
64
|
+
export const CONDITION_FORMS = [
|
|
65
|
+
"pr-merged:https://github.com/owner/repo/pull/123",
|
|
66
|
+
"issue-closed:123",
|
|
67
|
+
"npm-version:omp-conductor@0.4.3",
|
|
68
|
+
] as const;
|
|
69
|
+
|
|
70
|
+
/** Probe for `npm-version`, injectable so tests never reach the network. */
|
|
71
|
+
export type NpmProbe = (spec: string) => Promise<boolean>;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* `npm view <spec> version` — exit 0 with non-empty output means the version is
|
|
75
|
+
* published. Bounded at 10 s because this runs inside a tick: a registry that
|
|
76
|
+
* hangs must cost one unevaluated condition, not the tick.
|
|
77
|
+
*/
|
|
78
|
+
export const probeNpmVersion: NpmProbe = async (spec) => {
|
|
79
|
+
const proc = Bun.spawn(["npm", "view", spec, "version"], { stdout: "pipe", stderr: "ignore" });
|
|
80
|
+
const timer = setTimeout(() => {
|
|
81
|
+
proc.kill();
|
|
82
|
+
}, 10_000);
|
|
83
|
+
try {
|
|
84
|
+
const [text, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
|
|
85
|
+
return code === 0 && text.trim().length > 0;
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
} finally {
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Check every open decision that carries a condition and has not met it yet.
|
|
95
|
+
*
|
|
96
|
+
* Errors are swallowed per row on purpose: this runs fire-and-forget beside a
|
|
97
|
+
* tick, and one deleted PR or one flaky `gh` call must not stop the rest of the
|
|
98
|
+
* pass. A condition that could not be checked is simply not met this time, and
|
|
99
|
+
* the next tick asks again for free.
|
|
100
|
+
*
|
|
101
|
+
* Returns the rows that just became met, so the caller can log what changed
|
|
102
|
+
* rather than a count.
|
|
103
|
+
*/
|
|
104
|
+
export async function evaluateDecisionConditions(
|
|
105
|
+
store: Store,
|
|
106
|
+
project: string,
|
|
107
|
+
tracker: Tracker,
|
|
108
|
+
probeNpm: NpmProbe,
|
|
109
|
+
now: () => number,
|
|
110
|
+
): Promise<DecisionRecord[]> {
|
|
111
|
+
const met: DecisionRecord[] = [];
|
|
112
|
+
for (const decision of store.openDecisions(project)) {
|
|
113
|
+
if (decision.condition === undefined || decision.conditionMetAt !== undefined) continue;
|
|
114
|
+
const condition = parseCondition(decision.condition);
|
|
115
|
+
if (condition === undefined) continue;
|
|
116
|
+
let satisfied = false;
|
|
117
|
+
try {
|
|
118
|
+
if (condition.kind === "pr-merged") {
|
|
119
|
+
satisfied = (await tracker.prState(condition.url)) === "merged";
|
|
120
|
+
} else if (condition.kind === "issue-closed") {
|
|
121
|
+
satisfied = (await tracker.issueState(condition.issue)) === "closed";
|
|
122
|
+
} else {
|
|
123
|
+
satisfied = await probeNpm(condition.spec);
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (!satisfied) continue;
|
|
129
|
+
if (store.markDecisionConditionMet(decision.id, now())) {
|
|
130
|
+
met.push({ ...decision, conditionMetAt: now() });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return met;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Whole hours, then days — the granularity an operator reads a digest at. */
|
|
137
|
+
function age(since: number, now: number): string {
|
|
138
|
+
const minutes = Math.max(0, Math.round((now - since) / 60_000));
|
|
139
|
+
if (minutes < 60) return `${minutes}m`;
|
|
140
|
+
const hours = Math.round(minutes / 60);
|
|
141
|
+
return hours < 48 ? `${hours}h` : `${Math.round(hours / 24)}d`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The open-decision block appended to a tick prompt, or `""` when nothing is
|
|
146
|
+
* owed.
|
|
147
|
+
*
|
|
148
|
+
* This is the half that makes the ledger worth having: the session is told what
|
|
149
|
+
* it still owes on every tick, from the store, so "what did I ask them?" stops
|
|
150
|
+
* being a memory question. A row whose condition has come true is flagged, not
|
|
151
|
+
* merely listed — that is the difference between a parked question and one to
|
|
152
|
+
* act on now.
|
|
153
|
+
*/
|
|
154
|
+
export function formatDecisionDigest(open: readonly DecisionRecord[], now = Date.now()): string {
|
|
155
|
+
if (open.length === 0) return "";
|
|
156
|
+
const lines = [
|
|
157
|
+
`Open operator decisions (${open.length}) — resolve or withdraw each with omp-conductor decision resolve|withdraw <id>:`,
|
|
158
|
+
];
|
|
159
|
+
for (const d of open) {
|
|
160
|
+
const flag = d.conditionMetAt === undefined ? "" : " [CONDITION MET — act on this now]";
|
|
161
|
+
lines.push(`- ${d.id} (${age(d.askedAt, now)}, blocks ${d.blocks ?? "nothing"})${flag}: ${d.question}`);
|
|
162
|
+
}
|
|
163
|
+
return lines.join("\n");
|
|
164
|
+
}
|