@opsee/cli 0.11.9
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 +1962 -0
- package/bin/opsee.js +28 -0
- package/package.json +40 -0
- package/skills/README.md +3 -0
- package/skills/to-issues/SKILL.md +92 -0
- package/skills/to-issues/agents/openai.yaml +5 -0
- package/skills/to-spec/SKILL.md +79 -0
- package/skills/to-spec/agents/openai.yaml +5 -0
- package/skills/wayfinder/SKILL.md +138 -0
- package/skills/wayfinder/agents/openai.yaml +5 -0
- package/src/args.ts +676 -0
- package/src/cli.ts +341 -0
- package/src/commands/account.ts +121 -0
- package/src/commands/deps.ts +11 -0
- package/src/commands/foreman-control.ts +242 -0
- package/src/commands/foreman-debug.ts +131 -0
- package/src/commands/foreman-plan.ts +213 -0
- package/src/commands/foreman-service.ts +186 -0
- package/src/commands/foreman-up.ts +165 -0
- package/src/commands/foreman-views.ts +398 -0
- package/src/commands/foreman.ts +465 -0
- package/src/commands/init.ts +176 -0
- package/src/commands/initiative.ts +192 -0
- package/src/commands/login.ts +24 -0
- package/src/commands/whoami.ts +15 -0
- package/src/foreman/account-store.ts +96 -0
- package/src/foreman/account.ts +474 -0
- package/src/foreman/claude-worker-adapter.ts +412 -0
- package/src/foreman/codex-worker-adapter.ts +472 -0
- package/src/foreman/completion-report.ts +153 -0
- package/src/foreman/core/context.ts +169 -0
- package/src/foreman/core/defects.ts +280 -0
- package/src/foreman/core/exec.ts +20 -0
- package/src/foreman/core/gates.ts +493 -0
- package/src/foreman/core/handoff.ts +163 -0
- package/src/foreman/core/install.ts +109 -0
- package/src/foreman/core/learnings.ts +368 -0
- package/src/foreman/core/outbox-tracker.ts +192 -0
- package/src/foreman/core/pin.ts +226 -0
- package/src/foreman/core/plan-context.ts +238 -0
- package/src/foreman/core/process-table.ts +535 -0
- package/src/foreman/core/reconcile.ts +227 -0
- package/src/foreman/core/report.ts +60 -0
- package/src/foreman/core/run.ts +2836 -0
- package/src/foreman/core/scheduler.ts +244 -0
- package/src/foreman/core/summary.ts +166 -0
- package/src/foreman/core/text.ts +97 -0
- package/src/foreman/core/transcripts.ts +38 -0
- package/src/foreman/core/triage.ts +138 -0
- package/src/foreman/core/verifier.ts +800 -0
- package/src/foreman/core/views.ts +940 -0
- package/src/foreman/core/work-contract.ts +152 -0
- package/src/foreman/core/workspace.ts +335 -0
- package/src/foreman/fake-handoff.ts +33 -0
- package/src/foreman/fake-learnings.ts +26 -0
- package/src/foreman/fake-remote-api.ts +70 -0
- package/src/foreman/fake-tracker-adapter.ts +355 -0
- package/src/foreman/fake-worker-adapter.ts +221 -0
- package/src/foreman/host.ts +75 -0
- package/src/foreman/local-dir.ts +28 -0
- package/src/foreman/opsee-tracker-adapter.ts +612 -0
- package/src/foreman/process-group.ts +160 -0
- package/src/foreman/remote-api.ts +283 -0
- package/src/foreman/run-recipe.ts +274 -0
- package/src/foreman/service-unit.ts +257 -0
- package/src/foreman/tracker-adapter.ts +298 -0
- package/src/foreman/triage-draft.ts +40 -0
- package/src/foreman/vendor.ts +23 -0
- package/src/foreman/verdict.ts +120 -0
- package/src/foreman/worker-adapter.ts +177 -0
- package/src/foreman/worker-process.ts +488 -0
- package/src/identity.ts +49 -0
- package/src/index.ts +3 -0
- package/src/init/managed.ts +84 -0
- package/src/init/mcp-config.ts +77 -0
- package/src/init/paths.ts +16 -0
- package/src/init/pointer-block.ts +45 -0
- package/src/init/project.ts +22 -0
- package/src/init/prompt.ts +45 -0
- package/src/init/run-recipe-config.ts +133 -0
- package/src/init/skills.ts +38 -0
- package/src/init/text.ts +22 -0
- package/src/init/tracker-doc.ts +106 -0
- package/src/opsee-config.ts +116 -0
- package/templates/issue-tracker.md +162 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `opsee foreman service install | uninstall | status` (story 16): the daemon under the machine's
|
|
3
|
+
* service manager. `install` writes the launchd LaunchAgent or the systemd user unit for the
|
|
4
|
+
* checkout it runs in and prints the commands that load it (runs them with --start); `uninstall`
|
|
5
|
+
* removes the file (stops it first with --stop); `status` says whether the unit file is present and
|
|
6
|
+
* whether a daemon is up. The unit runs `opsee foreman up`, nothing else: reboot continuity is the
|
|
7
|
+
* daemon's own Reconcile (ADR-0009), and the unit carries no credential.
|
|
8
|
+
*/
|
|
9
|
+
import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { dirname } from "node:path";
|
|
11
|
+
import type { AccountStore } from "../foreman/account-store.js";
|
|
12
|
+
import { ForemanError } from "../foreman/core/run.js";
|
|
13
|
+
import { serviceUnitFor, shellLine, type ServiceCommand, type ServiceTarget, type ServiceUnit } from "../foreman/service-unit.js";
|
|
14
|
+
import { liveDaemonPid, type DaemonProbe } from "./foreman.js";
|
|
15
|
+
|
|
16
|
+
export type ServiceAction = "install" | "uninstall" | "status";
|
|
17
|
+
|
|
18
|
+
export interface ForemanServiceArgs {
|
|
19
|
+
action: ServiceAction;
|
|
20
|
+
/** install: load and start the unit after writing it. */
|
|
21
|
+
start: boolean;
|
|
22
|
+
/** uninstall: stop and unload the unit before removing it. */
|
|
23
|
+
stop: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The result of one service-manager command: exit code and everything it printed. */
|
|
27
|
+
export interface ExecResult {
|
|
28
|
+
code: number;
|
|
29
|
+
output: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ForemanServiceDeps {
|
|
33
|
+
platform: NodeJS.Platform;
|
|
34
|
+
home: string;
|
|
35
|
+
uid?: number;
|
|
36
|
+
/** The node binary and the CLI entry the unit runs, both absolute. */
|
|
37
|
+
node: string;
|
|
38
|
+
bin: string;
|
|
39
|
+
/** The checkout the daemon serves: where the command runs. */
|
|
40
|
+
repoRoot: () => Promise<string | undefined>;
|
|
41
|
+
localDir: string;
|
|
42
|
+
pidFile: string;
|
|
43
|
+
/** How `status` tells a live daemon from a stale pid file (foreman.ts); the real probe by default. */
|
|
44
|
+
probe?: DaemonProbe;
|
|
45
|
+
/** The environment the unit inherits PATH from. */
|
|
46
|
+
env: NodeJS.ProcessEnv;
|
|
47
|
+
/** Registered Accounts: a key-by-name one names the variable the unit must not carry. */
|
|
48
|
+
store: AccountStore;
|
|
49
|
+
/** Runs a service-manager command (`launchctl`, `systemctl`) when asked to; never a shell. */
|
|
50
|
+
exec: (file: string, args: string[]) => Promise<ExecResult>;
|
|
51
|
+
out: (line: string) => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A variable name that looks like a credential, dropped from the unit whatever an Account says:
|
|
55
|
+
* the exclusion set from the store is the rule, this is the net under it. */
|
|
56
|
+
export const SECRET_NAME = /TOKEN|KEY|SECRET|PASSWORD/i;
|
|
57
|
+
|
|
58
|
+
/** The variables the unit must never hold: the CLI's own token, and every variable a key-by-name
|
|
59
|
+
* Account reads its key from (its keyRef is any name the developer chose, OPSEE_* included). */
|
|
60
|
+
export function credentialNames(store: AccountStore): Set<string> {
|
|
61
|
+
const names = new Set(["OPSEE_API_TOKEN"]);
|
|
62
|
+
for (const account of store.load()) if (account.type === "api_key") names.add(account.keyRef);
|
|
63
|
+
return names;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The OPSEE_* overrides set where install ran (backend URL, local paths), minus every credential:
|
|
67
|
+
* the daemon must find the same backend and files the developer's shell does, and the unit must
|
|
68
|
+
* never hold a token or a key (that is the credentials file's and the Account store's job). A name
|
|
69
|
+
* in `exclude` is dropped by rule; a name that merely looks like a secret is dropped as a net. */
|
|
70
|
+
export function carriedEnv(env: NodeJS.ProcessEnv, exclude: Iterable<string> = ["OPSEE_API_TOKEN"]): Record<string, string> {
|
|
71
|
+
const excluded = new Set(exclude);
|
|
72
|
+
const carried: Record<string, string> = {};
|
|
73
|
+
for (const [key, value] of Object.entries(env)) {
|
|
74
|
+
if (!key.startsWith("OPSEE_") || value === undefined) continue;
|
|
75
|
+
if (excluded.has(key) || SECRET_NAME.test(key)) continue;
|
|
76
|
+
carried[key] = value;
|
|
77
|
+
}
|
|
78
|
+
return carried;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function runForemanService(deps: ForemanServiceDeps, args: ForemanServiceArgs): Promise<number> {
|
|
82
|
+
const repoRoot = await deps.repoRoot();
|
|
83
|
+
if (!repoRoot) throw new ForemanError("Not inside a git repository; run foreman service from the checkout the daemon should serve.");
|
|
84
|
+
const target: ServiceTarget = {
|
|
85
|
+
home: deps.home,
|
|
86
|
+
node: deps.node,
|
|
87
|
+
bin: deps.bin,
|
|
88
|
+
repoRoot,
|
|
89
|
+
localDir: deps.localDir,
|
|
90
|
+
path: deps.env.PATH ?? "",
|
|
91
|
+
env: carriedEnv(deps.env, credentialNames(deps.store)),
|
|
92
|
+
uid: deps.uid,
|
|
93
|
+
};
|
|
94
|
+
const unit = serviceUnitFor(deps.platform, target);
|
|
95
|
+
if (!unit) throw new ForemanError(`No service manager is supported on ${deps.platform}; the daemon runs under launchd (macOS) or systemd (Linux).`);
|
|
96
|
+
|
|
97
|
+
switch (args.action) {
|
|
98
|
+
case "status":
|
|
99
|
+
return status(deps, unit);
|
|
100
|
+
case "install":
|
|
101
|
+
return install(deps, unit, repoRoot, args.start);
|
|
102
|
+
case "uninstall":
|
|
103
|
+
return uninstall(deps, unit, args.stop);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function status(deps: ForemanServiceDeps, unit: ServiceUnit): number {
|
|
108
|
+
const installed = existsSync(unit.path);
|
|
109
|
+
deps.out(installed ? `${unit.manager} unit installed (file present): ${unit.path}` : `${unit.manager} unit not installed (would be ${unit.path}); install it with: opsee foreman service install`);
|
|
110
|
+
const pid = liveDaemonPid(deps.pidFile, deps.probe);
|
|
111
|
+
deps.out(pid !== undefined ? `Foreman daemon up (pid ${pid})` : `Foreman daemon not running (no live pid in ${deps.pidFile})`);
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function install(deps: ForemanServiceDeps, unit: ServiceUnit, repoRoot: string, start: boolean): Promise<number> {
|
|
116
|
+
const { out } = deps;
|
|
117
|
+
mkdirSync(dirname(unit.path), { recursive: true });
|
|
118
|
+
// The log the unit points at lives in the local dir, which the service manager will not create.
|
|
119
|
+
mkdirSync(deps.localDir, { recursive: true, mode: 0o700 });
|
|
120
|
+
// Owner-only: the unit names this machine's paths and backend, and `mode` only applies to a new
|
|
121
|
+
// file, so an existing one is tightened too.
|
|
122
|
+
writeFileSync(unit.path, unit.content, { mode: 0o600 });
|
|
123
|
+
chmodSync(unit.path, 0o600);
|
|
124
|
+
const replaces = unit.manager === "launchd" ? "unloads a loaded copy before loading this one" : "restarts a running copy on this one";
|
|
125
|
+
out(`Wrote ${unit.manager} unit ${unit.path}: runs "opsee foreman up" in ${repoRoot} at ${unit.manager === "launchd" ? "login" : "boot"} and restarts it after a crash; starting it ${replaces}.`);
|
|
126
|
+
for (const account of deps.store.load()) {
|
|
127
|
+
if (account.type === "api_key") {
|
|
128
|
+
out(`Note: Account "${account.name}" reads its key from $${account.keyRef}, which the unit does not carry; add it to the unit's environment yourself before starting it (the Foreman never writes a credential).`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
for (const note of unit.notes) out(note);
|
|
132
|
+
if (!start) {
|
|
133
|
+
out("To load and start it:");
|
|
134
|
+
for (const command of unit.start) out(` ${shellLine(command.argv)}`);
|
|
135
|
+
out("Or rerun with --start to have this command do it.");
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
return run(deps, "start", unit.start);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function uninstall(deps: ForemanServiceDeps, unit: ServiceUnit, stop: boolean): Promise<number> {
|
|
142
|
+
const { out } = deps;
|
|
143
|
+
if (!existsSync(unit.path)) {
|
|
144
|
+
out(`${unit.manager} unit not installed (no ${unit.path}); nothing to remove.`);
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
if (stop) {
|
|
148
|
+
const code = await run(deps, "stop", unit.stop);
|
|
149
|
+
if (code !== 0) return code;
|
|
150
|
+
} else {
|
|
151
|
+
out("If it is loaded, stop it first (or rerun with --stop):");
|
|
152
|
+
for (const command of unit.stop) out(` ${shellLine(command.argv)}`);
|
|
153
|
+
}
|
|
154
|
+
rmSync(unit.path, { force: true });
|
|
155
|
+
out(`Removed ${unit.path}`);
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Whether a non-zero exit is one the command declares harmless (launchctl bootout exits 3, "No
|
|
160
|
+
* such process", when the unit was not loaded). */
|
|
161
|
+
function toleratedExit(command: ServiceCommand, result: ExecResult): boolean {
|
|
162
|
+
const t = command.tolerated;
|
|
163
|
+
return t !== undefined && (t.codes.includes(result.code) || (t.output !== undefined && t.output.test(result.output)));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Runs the service manager's commands in order, stopping at the first that fails. */
|
|
167
|
+
async function run(deps: ForemanServiceDeps, what: "start" | "stop", commands: ServiceCommand[]): Promise<number> {
|
|
168
|
+
for (const command of commands) {
|
|
169
|
+
const [file, ...args] = command.argv;
|
|
170
|
+
const line = shellLine(command.argv);
|
|
171
|
+
deps.out(`$ ${line}`);
|
|
172
|
+
const result = await deps.exec(file, args);
|
|
173
|
+
const output = result.output.trim();
|
|
174
|
+
if (result.code !== 0 && toleratedExit(command, result)) {
|
|
175
|
+
deps.out(`(${command.tolerated!.means}: "${line}" exited ${result.code})`);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (output) deps.out(output);
|
|
179
|
+
if (result.code !== 0) {
|
|
180
|
+
deps.out(`${what === "start" ? "Start" : "Stop"} failed: "${line}" exited ${result.code}`);
|
|
181
|
+
return 1;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
deps.out(what === "start" ? "Foreman service started; watch it with: opsee foreman service status" : "Foreman service stopped");
|
|
185
|
+
return 0;
|
|
186
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `opsee foreman up` (story 13): the daemon that idles until given work. One per machine, guarded
|
|
3
|
+
* by the pid file: a stale file is overwritten, so a service manager (foreman service install,
|
|
4
|
+
* story 16) can start it cold. Stale is what a reboot or a kill -9 leaves: a file written before
|
|
5
|
+
* this boot (whatever pid it names, since pids restart low and last boot's number may be some other
|
|
6
|
+
* process's now), or one naming a pid that is dead or that belongs to another program (foreman.ts
|
|
7
|
+
* liveDaemonPid). A live Foreman that is not this process ends it with exit 3, not 1: not a
|
|
8
|
+
* crash, so systemd does not restart it (RestartPreventExitStatus), and the operator is told to
|
|
9
|
+
* stop the other one. Each
|
|
10
|
+
* tick it Reconciles the Process Table (story 51: every Initiative and Account it has rows for, so
|
|
11
|
+
* a Worker left by a dead daemon is resumed even when nobody asks for a Run) and drains the
|
|
12
|
+
* outbox, then serves the oldest pending Run request `foreman run` queued, in the foreground of
|
|
13
|
+
* this process, and idles otherwise. A request whose Initiative is paused (story 55) waits
|
|
14
|
+
* pending, and a Run paused midway is put back to pending, so one paused Initiative never holds
|
|
15
|
+
* the daemon. A stop signal ends the Run after the dispatch in flight (the
|
|
16
|
+
* process hooks stop the Worker itself) and removes the pid file. On a laptop it warns at start
|
|
17
|
+
* (story 17). A Run it serves fills its Account's Slots (core/scheduler.ts) like a foreground one;
|
|
18
|
+
* what this still does one at a time is the Runs themselves, one request per tick.
|
|
19
|
+
*/
|
|
20
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { dirname } from "node:path";
|
|
22
|
+
import type { AccountStore } from "../foreman/account-store.js";
|
|
23
|
+
import { OutboxTracker } from "../foreman/core/outbox-tracker.js";
|
|
24
|
+
import type { RunRequestRow } from "../foreman/core/process-table.js";
|
|
25
|
+
import { ForemanError, PAUSE_POLL_MS, reconcileTick, runForeman } from "../foreman/core/run.js";
|
|
26
|
+
import { count } from "../foreman/core/text.js";
|
|
27
|
+
import { sleepWarning } from "../foreman/host.js";
|
|
28
|
+
import { EXIT_ANOTHER_FOREMAN } from "../foreman/service-unit.js";
|
|
29
|
+
import { errorMessage } from "../foreman/worker-process.js";
|
|
30
|
+
|
|
31
|
+
/** The daemon's exit when another Foreman holds the pid file (defined beside the units that read it). */
|
|
32
|
+
export { EXIT_ANOTHER_FOREMAN };
|
|
33
|
+
import { checkLearningsFile, chooseAccount, liveDaemonPid, reconciled, runDepsFor, summarize, type ForemanLocal, type SharedDeps } from "./foreman.js";
|
|
34
|
+
|
|
35
|
+
/** How long the daemon idles between ticks when no Run is requested; the same interval a
|
|
36
|
+
* foreground Run waits through a pause at. */
|
|
37
|
+
export const DEFAULT_DAEMON_TICK_MS = PAUSE_POLL_MS;
|
|
38
|
+
|
|
39
|
+
export interface ForemanUpDeps extends SharedDeps {
|
|
40
|
+
store: AccountStore;
|
|
41
|
+
/** The repository the daemon serves: the checkout the command runs in. */
|
|
42
|
+
repoRoot: () => Promise<string | undefined>;
|
|
43
|
+
local: ForemanLocal;
|
|
44
|
+
/** Resolves when the daemon should stop (SIGINT/SIGTERM); it finishes the dispatch in flight first. */
|
|
45
|
+
untilStop: Promise<void>;
|
|
46
|
+
/** The machine (story 17): a laptop that may sleep gets a warning at start, a desktop or server none. */
|
|
47
|
+
host: { platform: NodeJS.Platform; isLaptop: () => Promise<boolean> };
|
|
48
|
+
tickMs?: number;
|
|
49
|
+
sleep?: (ms: number) => Promise<void>;
|
|
50
|
+
pid?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function runForemanUp(deps: ForemanUpDeps): Promise<number> {
|
|
54
|
+
const { local, out } = deps;
|
|
55
|
+
const pid = deps.pid ?? process.pid;
|
|
56
|
+
const other = liveDaemonPid(local.pidFile, local.probe);
|
|
57
|
+
if (other !== undefined && other !== pid) {
|
|
58
|
+
out(`Foreman already up (pid ${other}); stop it first, or queue work for it: opsee foreman run <initiativeId>`);
|
|
59
|
+
return EXIT_ANOTHER_FOREMAN;
|
|
60
|
+
}
|
|
61
|
+
const repoRoot = await deps.repoRoot();
|
|
62
|
+
if (!repoRoot) throw new ForemanError("Not inside a git repository; run foreman up from the checkout the Initiatives' Tasks are about.");
|
|
63
|
+
mkdirSync(dirname(local.pidFile), { recursive: true, mode: 0o700 });
|
|
64
|
+
writeFileSync(local.pidFile, `${pid}\n`, { mode: 0o600 });
|
|
65
|
+
|
|
66
|
+
let stopping = false;
|
|
67
|
+
void deps.untilStop.then(() => {
|
|
68
|
+
stopping = true;
|
|
69
|
+
out("Foreman: stopping after the dispatch in flight");
|
|
70
|
+
});
|
|
71
|
+
const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
72
|
+
const tickMs = deps.tickMs ?? DEFAULT_DAEMON_TICK_MS;
|
|
73
|
+
const requeued = local.table.requeueRunning();
|
|
74
|
+
out(`Foreman up (pid ${pid}): repository ${repoRoot}, Process Table ${local.table.path}${requeued ? `, ${count(requeued, "Run request", "Run requests")} put back to pending` : ""}`);
|
|
75
|
+
const learningsConfig = checkLearningsFile(repoRoot);
|
|
76
|
+
if (learningsConfig.refused) out(`learnings: ${learningsConfig.refused}`);
|
|
77
|
+
out("Waiting for Runs: opsee foreman run <initiativeId> [--account <name>] [--once] [--task <id>] from another terminal");
|
|
78
|
+
if (await deps.host.isLaptop()) for (const line of sleepWarning(deps.host.platform)) out(line);
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
while (!stopping) {
|
|
82
|
+
await idleReconcile(deps, repoRoot);
|
|
83
|
+
if (stopping) break;
|
|
84
|
+
const request = local.table.takeRun();
|
|
85
|
+
if (request) {
|
|
86
|
+
await serveRun(deps, repoRoot, request, () => stopping, deps.untilStop);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
await Promise.race([sleep(tickMs), deps.untilStop]);
|
|
90
|
+
}
|
|
91
|
+
} finally {
|
|
92
|
+
rmSync(local.pidFile, { force: true });
|
|
93
|
+
}
|
|
94
|
+
out("Foreman down");
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The idle tick: every (Initiative, Account) the Process Table has rows for gets a Reconcile, and
|
|
99
|
+
* whatever the outbox holds is offered to the backend. One group failing never stops the others. */
|
|
100
|
+
async function idleReconcile(deps: ForemanUpDeps, repoRoot: string): Promise<void> {
|
|
101
|
+
const { local, out } = deps;
|
|
102
|
+
const groups = new Map<string, { initiativeId: number; account: string }>();
|
|
103
|
+
for (const row of local.table.liveWorkers()) groups.set(`${row.initiativeId}|${row.account}`, { initiativeId: row.initiativeId, account: row.account });
|
|
104
|
+
const accounts = deps.store.load();
|
|
105
|
+
for (const { initiativeId, account: name } of groups.values()) {
|
|
106
|
+
const account = accounts.find((a) => a.name === name);
|
|
107
|
+
if (!account) {
|
|
108
|
+
out(`reconcile: Initiative ${initiativeId} has Workers under Account "${name}", which is no longer registered; leaving them`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const outcome = await reconcileTick(await runDepsFor(deps, account, repoRoot, local, { failover: false }), initiativeId);
|
|
113
|
+
if (outcome.length) out(`reconcile: Initiative ${initiativeId}: ${summarize(reconciled(outcome)).line}`);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
out(`reconcile: Initiative ${initiativeId} on Account "${name}" failed, retrying next tick: ${errorMessage(error)}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (local.table.pending().length) {
|
|
119
|
+
try {
|
|
120
|
+
await new OutboxTracker(deps.tracker, local.table, out).drain();
|
|
121
|
+
} catch (error) {
|
|
122
|
+
out(`outbox: drain failed, retrying next tick: ${errorMessage(error)}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function serveRun(deps: ForemanUpDeps, repoRoot: string, request: RunRequestRow, shouldStop: () => boolean, untilStop: Promise<void>): Promise<void> {
|
|
128
|
+
const { local, out } = deps;
|
|
129
|
+
const label = `Run #${request.id} (Initiative ${request.initiativeId})`;
|
|
130
|
+
if (request.repoRoot && request.repoRoot !== repoRoot) {
|
|
131
|
+
const message = `requested from ${request.repoRoot}, but this daemon serves ${repoRoot}`;
|
|
132
|
+
local.table.finishRun(request.id, "failed", message);
|
|
133
|
+
out(`${label}: refused: ${message}`);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
out(`${label}: starting`);
|
|
137
|
+
try {
|
|
138
|
+
const account = chooseAccount(deps.store.load(), request.account);
|
|
139
|
+
const outcome = await (deps.run ?? runForeman)(await runDepsFor(deps, account, repoRoot, local, { maxTurns: request.maxTurns, stallTimeoutMs: request.stallTimeoutMs }), {
|
|
140
|
+
initiativeId: request.initiativeId,
|
|
141
|
+
once: request.once,
|
|
142
|
+
taskId: request.taskId,
|
|
143
|
+
shouldStop,
|
|
144
|
+
// The same signal as a promise: a Reconcile turn waiting for one of the Run's Slots abandons
|
|
145
|
+
// the wait on it, so the stop is not deferred until a Worker turn ends (core/scheduler.ts).
|
|
146
|
+
untilStop,
|
|
147
|
+
});
|
|
148
|
+
const { line, needingAttention, blocked } = summarize(outcome);
|
|
149
|
+
if (outcome.paused) {
|
|
150
|
+
// The Initiative was paused mid-Run (story 55): the request goes back to pending, where
|
|
151
|
+
// `takeRun` leaves it until `foreman resume`, and this daemon is free for other Initiatives.
|
|
152
|
+
const note = `paused after ${line}; waits for foreman resume ${request.initiativeId}`;
|
|
153
|
+
local.table.requeueRun(request.id, note);
|
|
154
|
+
out(`${label}: ${note}`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const clean = needingAttention === 0 && blocked === 0;
|
|
158
|
+
local.table.finishRun(request.id, clean ? "done" : "failed", clean ? undefined : [needingAttention ? `${needingAttention} needing attention` : "", blocked ? `${count(blocked, "Task")} blocked before dispatch` : ""].filter(Boolean).join(", "));
|
|
159
|
+
out(`${label}: over: ${line}`);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
const message = errorMessage(error);
|
|
162
|
+
local.table.finishRun(request.id, "failed", message);
|
|
163
|
+
out(`${label}: failed: ${message}`);
|
|
164
|
+
}
|
|
165
|
+
}
|