omp-conductor 0.15.12 → 0.15.13
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/REFERENCE.md +9 -4
- package/package.json +1 -1
- package/src/commands/restart.ts +81 -54
- package/src/commands/stop.ts +45 -22
- package/src/daemon.ts +253 -54
- package/src/doctor.ts +241 -4
- package/src/escalate.ts +8 -0
- package/src/fleet.ts +49 -2
- package/src/lifecycle.ts +113 -2
- package/src/omp.ts +24 -0
- package/src/orchestrator-down.ts +231 -0
- package/src/orchestrator-tick.ts +7 -0
- package/src/orchestrator.ts +14 -0
- package/src/release-policy.ts +163 -18
- package/src/setup-host.ts +386 -17
- package/src/setup-install.ts +40 -2
- package/src/setup-wizard.ts +278 -113
- package/src/stop-provenance.ts +66 -0
- package/src/store.ts +181 -0
- package/src/types.ts +111 -0
- package/src/upgrade.ts +26 -6
- package/src/verbs/protocol.ts +16 -3
- package/src/verbs/server.ts +27 -1
- package/src/wizard-ui.ts +261 -46
- package/src/worker.ts +21 -0
package/src/setup-host.ts
CHANGED
|
@@ -23,8 +23,10 @@ import {
|
|
|
23
23
|
import {
|
|
24
24
|
legacyArmedMarkerPath,
|
|
25
25
|
readTickConfig,
|
|
26
|
+
parseHerdrAgents,
|
|
26
27
|
TICK_CONFIG_FILE,
|
|
27
28
|
tickConfigMatchesProject,
|
|
29
|
+
type HerdrAgentList,
|
|
28
30
|
type TickConfig,
|
|
29
31
|
} from "./orchestrator-tick.ts";
|
|
30
32
|
import { formatStep, type PrivilegedStep } from "./privileged.ts";
|
|
@@ -142,6 +144,32 @@ export interface HostRuntimePlan {
|
|
|
142
144
|
herdrConfigTarget?: string;
|
|
143
145
|
/** Why no pane-shell key is planned (unusable login shell, …). */
|
|
144
146
|
herdrConfigProblem?: string;
|
|
147
|
+
/**
|
|
148
|
+
* A loud note when the pane-shell merge kept an explicit
|
|
149
|
+
* `resume_agents_on_restore = true` — {@link renderHerdrConfig} preserves
|
|
150
|
+
* that value on purpose (it may be a desktop host), and the plan says so
|
|
151
|
+
* rather than silently overriding a deliberate choice.
|
|
152
|
+
*/
|
|
153
|
+
herdrConfigWarning?: string;
|
|
154
|
+
/**
|
|
155
|
+
* The herdr-conductor plugin's `config.env`, staged like the pane-shell
|
|
156
|
+
* config and merged into the live plugin config dir post-consent: the file
|
|
157
|
+
* recover.sh sources (`herdr/bin/recover.sh`) — TARGET_SESSION, FLEET_CWDS
|
|
158
|
+
* (derived from every configured project), TELEGRAM_ENV, ACCESS_JSON.
|
|
159
|
+
* Present exactly when {@link herdrUnit} is; conductor-owned keys are
|
|
160
|
+
* patched in place, operator keys survive untouched.
|
|
161
|
+
*/
|
|
162
|
+
herdrEnv?: PlannedWrite<string>;
|
|
163
|
+
/** The live plugin config.env {@link herdrEnv} merges into, post-consent. */
|
|
164
|
+
herdrEnvTarget?: string;
|
|
165
|
+
/**
|
|
166
|
+
* When {@link planTick} rewrote a shared-default `agentName`, the live herdr
|
|
167
|
+
* pane still carries the old identity — the ticking decline that names the
|
|
168
|
+
* fix (`herdr agent rename <pane> <name>`). Setup performs the rename after
|
|
169
|
+
* the install when the session is reachable and exactly one pane matches;
|
|
170
|
+
* anything else surfaces the exact command (never guesses a pane).
|
|
171
|
+
*/
|
|
172
|
+
agentRename?: { session: string; from: string; to: string };
|
|
145
173
|
tick?: PlannedWrite<TickConfig>;
|
|
146
174
|
/** The `AGENTS.md` — composed-brief symlink — placed in the fleet cwd. */
|
|
147
175
|
briefLink?: BriefLinkPlan;
|
|
@@ -599,9 +627,25 @@ export function usableHerdrShell(shell: string): boolean {
|
|
|
599
627
|
* writes **no** key at all — the file passes through byte-identical, because
|
|
600
628
|
* "pin the shell" must never mean "write the word `unknown`".
|
|
601
629
|
*/
|
|
602
|
-
export
|
|
630
|
+
export interface HerdrConfigRender {
|
|
631
|
+
content: string;
|
|
632
|
+
/**
|
|
633
|
+
* Set when the merged config kept an explicit `resume_agents_on_restore =
|
|
634
|
+
* true` — preserved on purpose (it may be a deliberate desktop config), but
|
|
635
|
+
* loudly flagged because the headless shape herdr's own README calls
|
|
636
|
+
* recoverable requires `false`. When absent, the value is `false` (written
|
|
637
|
+
* or already present) and nothing needs saying.
|
|
638
|
+
*/
|
|
639
|
+
warning?: string;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export function renderHerdrConfig(shell: string, existing?: string): HerdrConfigRender {
|
|
603
643
|
const text = existing ?? "";
|
|
604
|
-
|
|
644
|
+
// Unusable login shell → byte-identical pass-through, exactly as before: the
|
|
645
|
+
// "pin the shell" guarantee is all-or-nothing, and the fleet docs'
|
|
646
|
+
// `resume_agents_on_restore` key rides in on the same config merge, so an
|
|
647
|
+
// operator whose passwd entry cannot be resolved still gets nothing written.
|
|
648
|
+
if (!usableHerdrShell(shell)) return { content: text };
|
|
605
649
|
|
|
606
650
|
const quoted = `"${shell.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
607
651
|
const line = `default_shell = ${quoted}`;
|
|
@@ -612,23 +656,246 @@ export function renderHerdrConfig(shell: string, existing?: string): string {
|
|
|
612
656
|
// old guard was unanchored, so it matched a commented line the anchored
|
|
613
657
|
// replace did not — a config whose only mention was a comment took the
|
|
614
658
|
// "replace" branch, replaced nothing, and silently wrote nothing at all.
|
|
615
|
-
// Any existing
|
|
659
|
+
// Any existing key keeps its position and gets the new value in place —
|
|
616
660
|
// even when the value already matches, so a re-run stays idempotent instead
|
|
617
661
|
// of duplicating the key.
|
|
618
662
|
const keyLine = /^([ \t]*default_shell[ \t]*=\s*).*$/m;
|
|
663
|
+
let content: string;
|
|
619
664
|
if (keyLine.test(text)) {
|
|
620
|
-
|
|
665
|
+
content = text.replace(keyLine, `$1${quoted}`);
|
|
666
|
+
} else {
|
|
667
|
+
const lines = text.split("\n");
|
|
668
|
+
const table = lines.findIndex((l) => /^\[terminal\]\s*$/.test(l));
|
|
669
|
+
if (table === -1) {
|
|
670
|
+
const body = text.replace(/\s+$/, "");
|
|
671
|
+
content = `${body.length > 0 ? body + "\n" : ""}[terminal]\n${line}\n`;
|
|
672
|
+
} else {
|
|
673
|
+
// The table exists but has no default_shell; drop the key right under it.
|
|
674
|
+
lines.splice(table + 1, 0, line);
|
|
675
|
+
content = lines.join("\n");
|
|
676
|
+
}
|
|
621
677
|
}
|
|
622
678
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
679
|
+
// [session] resume_agents_on_restore = false, the same anchored, comment-aware
|
|
680
|
+
// merge as default_shell: a real key is kept at its position, a commented-out
|
|
681
|
+
// mention is not a key. The one difference is explicit `true` (the value an
|
|
682
|
+
// operator pinned on a desktop host): it is preserved deliberately, and the
|
|
683
|
+
// caller warns about it rather than silently flipping a deliberate choice.
|
|
684
|
+
const resumeLine = "resume_agents_on_restore = false";
|
|
685
|
+
const resumeKey = /^[ \t]*resume_agents_on_restore[ \t]*=\s*([^#\n]*)/m;
|
|
686
|
+
const resumeMatch = resumeKey.exec(content);
|
|
687
|
+
const resumeValue = resumeMatch?.[1]?.trim().split(/\s+/)[0];
|
|
688
|
+
if (resumeMatch !== null) {
|
|
689
|
+
if (resumeValue === "false") {
|
|
690
|
+
content = content.replace(/^([ \t]*resume_agents_on_restore[ \t]*=\s*)[^#\n]*/m, `$1false`);
|
|
691
|
+
} else if (resumeValue !== undefined && resumeValue.length > 0) {
|
|
692
|
+
return {
|
|
693
|
+
content,
|
|
694
|
+
warning:
|
|
695
|
+
`explicit resume_agents_on_restore = ${resumeValue} kept as-is (could be a deliberate desktop config); ` +
|
|
696
|
+
"a headless fleet host leaves no live terminal for restored panes unless it is false",
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
} else {
|
|
700
|
+
const lines = content.split("\n");
|
|
701
|
+
const sessionTable = lines.findIndex((l) => /^\[session\]\s*$/.test(l));
|
|
702
|
+
if (sessionTable === -1) {
|
|
703
|
+
const body = content.replace(/\s+$/, "");
|
|
704
|
+
content = `${body.length > 0 ? body + "\n" : ""}[session]\n${resumeLine}\n`;
|
|
705
|
+
} else {
|
|
706
|
+
// The table exists but has no resume key; drop the key right under it.
|
|
707
|
+
lines.splice(sessionTable + 1, 0, resumeLine);
|
|
708
|
+
content = lines.join("\n");
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
return { content };
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* The values `setup host` owns in the herdr-conductor plugin's `config.env`.
|
|
716
|
+
*
|
|
717
|
+
* recover.sh sources the file (`herdr/bin/recover.sh`), and conductor writes
|
|
718
|
+
* exactly the keys that file reads to resolve a fleet. `FLEET_CWDS` is the
|
|
719
|
+
* multi-fleet form of the legacy single `FLEET_CWD`, so the renderer always
|
|
720
|
+
* writes the former; the legacy key is an operator key and survives untouched.
|
|
721
|
+
*/
|
|
722
|
+
export interface HerdrConductorEnvValues {
|
|
723
|
+
/** The Herdr session that owns the fleet: TARGET_SESSION. */
|
|
724
|
+
targetSession: string;
|
|
725
|
+
/** Every configured project's fleet cwd, in config order (FLEET_CWDS). */
|
|
726
|
+
fleetCwds: readonly string[];
|
|
727
|
+
/** Absolute path of omp-telegram's `.env` (TELEGRAM_ENV). */
|
|
728
|
+
telegramEnv: string;
|
|
729
|
+
/** Absolute path of omp-telegram's `access.json` (ACCESS_JSON). */
|
|
730
|
+
accessJson: string;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** The keys `setup` patches; every other line in the file is operator-owned. */
|
|
734
|
+
const CONDUCTOR_ENV_KEYS = ["TARGET_SESSION", "FLEET_CWDS", "TELEGRAM_ENV", "ACCESS_JSON"] as const;
|
|
735
|
+
|
|
736
|
+
const ENV_VALUE_ESCAPE = /[\\"$]/g;
|
|
737
|
+
|
|
738
|
+
/** One `KEY=value` line, with the value shell-quoted so recover.sh's `.`
|
|
739
|
+
* sourcing reads paths with spaces or `$` literally. */
|
|
740
|
+
function envLine(key: string, value: string): string {
|
|
741
|
+
return `${key}="${value.replace(ENV_VALUE_ESCAPE, "\\$&")}"`;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Render herdr-conductor's `config.env` — create it when missing; when present
|
|
746
|
+
* patch only conductor-owned keys and pass every other line through verbatim
|
|
747
|
+
* (an operator's `RECOVER_RECHECK_SECONDS`, `BOOTSTRAP_RESUME`, AGENT_NAME,
|
|
748
|
+
* FLEET_CWD … must survive a re-run).
|
|
749
|
+
*
|
|
750
|
+
* A conductor-owned key that already exists keeps its position and gets the new
|
|
751
|
+
* value; a missing one is appended at the end. The anchored `^[ \t]*KEY=` guard
|
|
752
|
+
* mirrors the TOML merge's comment-awareness: a line starting with `#` is a
|
|
753
|
+
* comment, not a key, so a commented draft is neither patched nor mistaken for
|
|
754
|
+
* an existing real key (which would block the append).
|
|
755
|
+
*/
|
|
756
|
+
export function renderHerdrConductorEnv(values: HerdrConductorEnvValues, existing?: string): string {
|
|
757
|
+
const lines = (existing ?? "").split("\n");
|
|
758
|
+
// The split of a file ending in a newline leaves one empty trailing element
|
|
759
|
+
// (and a fresh file has exactly one empty element, nothing more); both are
|
|
760
|
+
// the join's terminator, not file content — the single join below re-adds it.
|
|
761
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
762
|
+
const headers: Record<(typeof CONDUCTOR_ENV_KEYS)[number], string> = {
|
|
763
|
+
TARGET_SESSION: values.targetSession,
|
|
764
|
+
FLEET_CWDS: values.fleetCwds.join(":"),
|
|
765
|
+
TELEGRAM_ENV: values.telegramEnv,
|
|
766
|
+
ACCESS_JSON: values.accessJson,
|
|
767
|
+
};
|
|
768
|
+
const seen = new Set<string>();
|
|
769
|
+
for (const index of lines.keys()) {
|
|
770
|
+
const line = lines[index]!;
|
|
771
|
+
const match = /^([ \t]*)([A-Za-z_][A-Za-z0-9_]*)([ \t]*=[ \t]*).*$/.exec(line);
|
|
772
|
+
if (match === null) continue;
|
|
773
|
+
const key = match[2] as (typeof CONDUCTOR_ENV_KEYS)[number];
|
|
774
|
+
if (!(key in headers)) continue;
|
|
775
|
+
seen.add(key);
|
|
776
|
+
lines[index] = `${match[1]}${envLine(key, headers[key])}`;
|
|
777
|
+
}
|
|
778
|
+
for (const key of CONDUCTOR_ENV_KEYS) {
|
|
779
|
+
if (!seen.has(key)) lines.push(envLine(key, headers[key]));
|
|
780
|
+
}
|
|
781
|
+
return `${lines.join("\n")}\n`;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Decide whether the fleet pane may be renamed, and if so what to run.
|
|
786
|
+
*
|
|
787
|
+
* The contract is written for the one-identity case and refuses to guess:
|
|
788
|
+
* - exactly one pane carries `from` (the restamped-away identity) → rename it;
|
|
789
|
+
* - zero, several, or an unreadable agent list → surface the exact command
|
|
790
|
+
* and the reason; never invent a pane.
|
|
791
|
+
*
|
|
792
|
+
* `command` is always the one-line remedy an operator can paste; on ambiguity
|
|
793
|
+
* it carries the generic pane placeholder because no specific pane may be
|
|
794
|
+
* guessed. It is the same shape the tick's decline path prints (`herdr agent
|
|
795
|
+
* rename <pane> <name>`), born from the session so the same command works from
|
|
796
|
+
* outside herdr too.
|
|
797
|
+
*/
|
|
798
|
+
export type AgentRenameVerdict =
|
|
799
|
+
| { kind: "rename"; pane: string; command: string }
|
|
800
|
+
| { kind: "flag"; command: string; reason: string };
|
|
801
|
+
|
|
802
|
+
export function agentRenameVerdict(
|
|
803
|
+
list: HerdrAgentList,
|
|
804
|
+
from: string,
|
|
805
|
+
to: string,
|
|
806
|
+
session: string,
|
|
807
|
+
): AgentRenameVerdict {
|
|
808
|
+
const commandFor = (pane: string): string => `herdr --session ${session} agent rename ${pane} ${to}`;
|
|
809
|
+
if (list.kind !== "ok") {
|
|
810
|
+
return {
|
|
811
|
+
kind: "flag",
|
|
812
|
+
command: commandFor("<pane>"),
|
|
813
|
+
reason: `herdr agent list is unreadable (${list.problem}) — cannot verify which pane to rename`,
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
const matches = list.agents.filter((a) => a.name === from);
|
|
817
|
+
if (matches.length === 1) {
|
|
818
|
+
return { kind: "rename", pane: matches[0]!.paneId, command: commandFor(matches[0]!.paneId) };
|
|
819
|
+
}
|
|
820
|
+
if (matches.length === 0) {
|
|
821
|
+
return {
|
|
822
|
+
kind: "flag",
|
|
823
|
+
command: commandFor("<pane>"),
|
|
824
|
+
reason: `no live pane is registered as agent "${from}" — nothing was renamed (an unregistered fleet pane must be named by hand)`,
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
return {
|
|
828
|
+
kind: "flag",
|
|
829
|
+
command: commandFor("<pane>"),
|
|
830
|
+
reason: `${matches.length} panes (${matches.map((a) => a.paneId).join(", ")}) are registered as "${from}" — refusing to guess which is the fleet pane`,
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** The live herdr CLI seam setup-host shells out for the one-shot rename. */
|
|
835
|
+
export interface AgentRenameDeps {
|
|
836
|
+
/** `herdr --session <s> agent list`'s one JSON line. */
|
|
837
|
+
list(session: string): HerdrAgentList;
|
|
838
|
+
/** `herdr --session <s> agent rename <pane> <name>`. */
|
|
839
|
+
rename(session: string, pane: string, name: string): { ok: boolean; problem?: string };
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** The production wiring: the same CLI `recover.sh` and the tick both use. */
|
|
843
|
+
export const DEFAULT_AGENT_RENAME_DEPS: AgentRenameDeps = {
|
|
844
|
+
list: (session) => {
|
|
845
|
+
const bin = Bun.which("herdr") ?? "herdr";
|
|
846
|
+
try {
|
|
847
|
+
const run = spawnSync(bin, ["--session", session, "agent", "list"], {
|
|
848
|
+
encoding: "utf8",
|
|
849
|
+
timeout: 3000,
|
|
850
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
851
|
+
});
|
|
852
|
+
if (run.error !== undefined) return { kind: "unavailable", problem: run.error.message };
|
|
853
|
+
if (run.status !== 0) return { kind: "unavailable", problem: `exit ${String(run.status)}: ${(run.stderr ?? "").trim().split("\n")[0] ?? ""}` };
|
|
854
|
+
return parseHerdrAgents(run.stdout ?? "");
|
|
855
|
+
} catch (err) {
|
|
856
|
+
return { kind: "unavailable", problem: err instanceof Error ? err.message : String(err) };
|
|
857
|
+
}
|
|
858
|
+
},
|
|
859
|
+
rename: (session, pane, name) => {
|
|
860
|
+
const bin = Bun.which("herdr") ?? "herdr";
|
|
861
|
+
try {
|
|
862
|
+
const run = spawnSync(bin, ["--session", session, "agent", "rename", pane, name], {
|
|
863
|
+
encoding: "utf8",
|
|
864
|
+
timeout: 3000,
|
|
865
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
866
|
+
});
|
|
867
|
+
if (run.error !== undefined) return { ok: false, problem: run.error.message };
|
|
868
|
+
if (run.status !== 0) {
|
|
869
|
+
return { ok: false, problem: `exit ${String(run.status)}: ${(run.stderr ?? "").trim().split("\n")[0] ?? ""}` };
|
|
870
|
+
}
|
|
871
|
+
return { ok: true };
|
|
872
|
+
} catch (err) {
|
|
873
|
+
return { ok: false, problem: err instanceof Error ? err.message : String(err) };
|
|
874
|
+
}
|
|
875
|
+
},
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
/** The directory `herdr plugin config-dir herdr-conductor` prints. */
|
|
879
|
+
export function herdrConductorPluginConfigDir(home: string): string {
|
|
880
|
+
return join(home, ".config", "herdr", "plugins", "config", "herdr-conductor");
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* The fleet cwd of every configured project, in config order — the FLEET_CWDS
|
|
885
|
+
* value recover.sh walks to find each fleet's `.conductor-tick.json`.
|
|
886
|
+
*
|
|
887
|
+
* Tries the real config first; when it cannot be read (a not-yet-configured
|
|
888
|
+
* box, install tests staging before a config exists), the named project's own
|
|
889
|
+
* cwd stands in — the same guarded fallback shape as `hostInstallWorkers` and
|
|
890
|
+
* `hostMultiProject`. Multi-project hosts get the full list when the config
|
|
891
|
+
* loads, which is the case the recovery path exists for.
|
|
892
|
+
*/
|
|
893
|
+
export function configuredFleetCwds(project: ProjectConfig | undefined): string[] {
|
|
894
|
+
try {
|
|
895
|
+
return loadConfig().projects.map((p) => tickCwdForProject(p));
|
|
896
|
+
} catch {
|
|
897
|
+
return project === undefined ? [] : [tickCwdForProject(project)];
|
|
628
898
|
}
|
|
629
|
-
// The table exists but has no default_shell; drop the key right under it.
|
|
630
|
-
lines.splice(table + 1, 0, line);
|
|
631
|
-
return lines.join("\n");
|
|
632
899
|
}
|
|
633
900
|
|
|
634
901
|
function tickSearchRoots(project: ProjectConfig): string[] {
|
|
@@ -730,7 +997,13 @@ function planBriefLink(project: ProjectConfig): BriefLinkPlan {
|
|
|
730
997
|
};
|
|
731
998
|
}
|
|
732
999
|
|
|
733
|
-
function planTick(project: ProjectConfig, telegramStateDir: string):
|
|
1000
|
+
function planTick(project: ProjectConfig, telegramStateDir: string): {
|
|
1001
|
+
write: PlannedWrite<TickConfig>;
|
|
1002
|
+
/** Set when the config was restamped from the shared default: the live herdr
|
|
1003
|
+
* pane still carries that old identity, and the plan must rename it
|
|
1004
|
+
* (or flag the exact command) after the tick lands. */
|
|
1005
|
+
previousAgentName?: string;
|
|
1006
|
+
} {
|
|
734
1007
|
const found = findProjectTick(project);
|
|
735
1008
|
const existing = found === undefined ? undefined : { path: found.path, config: found.config };
|
|
736
1009
|
|
|
@@ -758,7 +1031,19 @@ function planTick(project: ProjectConfig, telegramStateDir: string): PlannedWrit
|
|
|
758
1031
|
: existing.config.agentName,
|
|
759
1032
|
};
|
|
760
1033
|
const content = `${JSON.stringify(config, null, 2)}\n`;
|
|
761
|
-
return {
|
|
1034
|
+
return {
|
|
1035
|
+
write: { path, action: actionFor(path, content), content, value: config },
|
|
1036
|
+
// The rename only fires for a *re*-stamp: the old agentName was the shared
|
|
1037
|
+
// default (or absent, which every reader resolves to the same default), and
|
|
1038
|
+
// the new one is the project's own name. An already-project-stamped config
|
|
1039
|
+
// is not a collision and needs no rename. A brand-new config (no existing
|
|
1040
|
+
// tick at all) is not a restamp either: there was no previous identity for
|
|
1041
|
+
// a pane to be stuck with.
|
|
1042
|
+
...(existing !== undefined &&
|
|
1043
|
+
(existing.config.agentName === undefined || existing.config.agentName === DEFAULT_FLEET_AGENT_NAME)
|
|
1044
|
+
? { previousAgentName: DEFAULT_FLEET_AGENT_NAME }
|
|
1045
|
+
: {}),
|
|
1046
|
+
};
|
|
762
1047
|
}
|
|
763
1048
|
|
|
764
1049
|
export function planHostRuntime(
|
|
@@ -805,10 +1090,14 @@ export function planHostRuntime(
|
|
|
805
1090
|
// config byte-for-byte untouched, and the merge must land before the unit
|
|
806
1091
|
// restart that accompanies it.
|
|
807
1092
|
const herdrConfigStagedPath = join(stateDir(), "herdr-config.toml");
|
|
1093
|
+
const herdrEnvStagedPath = join(stateDir(), "herdr-conductor.env");
|
|
1094
|
+
const herdrEnvTarget = join(herdrConductorPluginConfigDir(runtime.home), "config.env");
|
|
808
1095
|
let herdrUnit: PlannedWrite<string> | undefined;
|
|
809
1096
|
let herdrConfig: PlannedWrite<string> | undefined;
|
|
810
1097
|
let herdrConfigTarget: string | undefined;
|
|
811
1098
|
let herdrConfigProblem: string | undefined;
|
|
1099
|
+
let herdrConfigWarning: string | undefined;
|
|
1100
|
+
let herdrEnv: PlannedWrite<string> | undefined;
|
|
812
1101
|
// The recovery playbook ships in this package's systemd/ dir; the same
|
|
813
1102
|
// bytes go into the staged copy, so version control is the single source.
|
|
814
1103
|
const recoverScriptPath = join(stateDir(), RECOVER_SCRIPT_FILE);
|
|
@@ -846,7 +1135,9 @@ export function planHostRuntime(
|
|
|
846
1135
|
`login shell ${JSON.stringify(runtime.shell)} is not an absolute path that exists on this host — ` +
|
|
847
1136
|
"no [terminal] default_shell is written, so herdr's own $SHELL → /bin/sh → passwd fallback applies";
|
|
848
1137
|
} else {
|
|
849
|
-
const
|
|
1138
|
+
const rendered = renderHerdrConfig(runtime.shell, existing);
|
|
1139
|
+
const configContent = rendered.content;
|
|
1140
|
+
if (rendered.warning !== undefined) herdrConfigWarning = rendered.warning;
|
|
850
1141
|
// The rendered file is parse-checked before anything may replace herdr's
|
|
851
1142
|
// live config. An input that is already broken (a duplicated key, say)
|
|
852
1143
|
// renders broken — the replace touches every real key, so two keys stay
|
|
@@ -872,6 +1163,37 @@ export function planHostRuntime(
|
|
|
872
1163
|
};
|
|
873
1164
|
herdrConfigTarget = herdrConfigPath;
|
|
874
1165
|
}
|
|
1166
|
+
// The herdr-conductor plugin's config.env (#541): recover.sh sources
|
|
1167
|
+
// `$HERDR_PLUGIN_CONFIG_DIR/config.env` and shrinks to legacy /root paths
|
|
1168
|
+
// when it is missing — on a host whose fleet lives elsewhere the recovery
|
|
1169
|
+
// still runs, only the page silently goes nowhere. Setup owns
|
|
1170
|
+
// TARGET_SESSION/FLEET_CWDS/TELEGRAM_ENV/ACCESS_JSON; the renderer patches
|
|
1171
|
+
// only those keys, so an operator's BOOTSTRAP_RESUME or
|
|
1172
|
+
// RECOVER_RECHECK_SECONDS survive a re-run.
|
|
1173
|
+
let existingEnv: string | undefined;
|
|
1174
|
+
try {
|
|
1175
|
+
existingEnv = readFileSync(herdrEnvTarget, "utf8");
|
|
1176
|
+
} catch {
|
|
1177
|
+
existingEnv = undefined;
|
|
1178
|
+
}
|
|
1179
|
+
const envContent = renderHerdrConductorEnv(
|
|
1180
|
+
{
|
|
1181
|
+
targetSession: runtime.herdrSession ?? DEFAULT_HERDR_SESSION,
|
|
1182
|
+
// Derived from every configured project's tick cwd, not from the
|
|
1183
|
+
// host-global unit the install runs for: FLEET_CWDS is what recover.sh
|
|
1184
|
+
// walks to find each fleet's `.conductor-tick.json`.
|
|
1185
|
+
fleetCwds: configuredFleetCwds(project),
|
|
1186
|
+
telegramEnv: join(telegramStateDir, ".env"),
|
|
1187
|
+
accessJson: join(telegramStateDir, "access.json"),
|
|
1188
|
+
},
|
|
1189
|
+
existingEnv,
|
|
1190
|
+
);
|
|
1191
|
+
herdrEnv = {
|
|
1192
|
+
path: herdrEnvStagedPath,
|
|
1193
|
+
action: actionFor(herdrEnvStagedPath, envContent),
|
|
1194
|
+
content: envContent,
|
|
1195
|
+
value: envContent,
|
|
1196
|
+
};
|
|
875
1197
|
}
|
|
876
1198
|
const installedPath = join(unitDir, STAGED_SERVICE_NAME);
|
|
877
1199
|
const installedHerdr = join(unitDir, DEFAULT_HERDR_UNIT);
|
|
@@ -942,6 +1264,19 @@ export function planHostRuntime(
|
|
|
942
1264
|
unprivileged: true,
|
|
943
1265
|
},
|
|
944
1266
|
]),
|
|
1267
|
+
// The herdr-conductor config.env (#541) rides the same consent-gated,
|
|
1268
|
+
// unprivileged, pre-restart lane: recover.sh sources it on every
|
|
1269
|
+
// startup, so the session server must come up already pointing at
|
|
1270
|
+
// this host's fleet cwds and telegram files.
|
|
1271
|
+
...(herdrEnv === undefined || actionFor(herdrEnvTarget, herdrEnv.content) === "keep"
|
|
1272
|
+
? []
|
|
1273
|
+
: [
|
|
1274
|
+
{
|
|
1275
|
+
title: `write the herdr-conductor config.env into ${herdrEnvTarget}`,
|
|
1276
|
+
argv: ["install", "-m", "0644", herdrEnvStagedPath, herdrEnvTarget],
|
|
1277
|
+
unprivileged: true,
|
|
1278
|
+
},
|
|
1279
|
+
]),
|
|
945
1280
|
{ title: `restart ${DEFAULT_HERDR_UNIT}`, argv: ["systemctl", "restart", DEFAULT_HERDR_UNIT] },
|
|
946
1281
|
]),
|
|
947
1282
|
];
|
|
@@ -958,7 +1293,10 @@ export function planHostRuntime(
|
|
|
958
1293
|
// The pane-shell file is a destination like the units: a re-run with all
|
|
959
1294
|
// units current but the config merge still pending must not report
|
|
960
1295
|
// "nothing to install" and skip the very write the plan exists to make.
|
|
961
|
-
(herdrConfig === undefined || actionFor(herdrConfigPath, herdrConfig.content) === "keep")
|
|
1296
|
+
(herdrConfig === undefined || actionFor(herdrConfigPath, herdrConfig.content) === "keep") &&
|
|
1297
|
+
// Same for the herdr-conductor config.env: pending env merge is pending
|
|
1298
|
+
// work, not an already-current install.
|
|
1299
|
+
(herdrEnv === undefined || actionFor(herdrEnvTarget, herdrEnv.content) === "keep");
|
|
962
1300
|
return {
|
|
963
1301
|
service,
|
|
964
1302
|
// The herdr unit and pane-shell config stand and fall together: no herdr, no
|
|
@@ -968,6 +1306,8 @@ export function planHostRuntime(
|
|
|
968
1306
|
...(herdrUnit === undefined ? {} : { herdrUnit }),
|
|
969
1307
|
...(herdrConfig === undefined ? {} : { herdrConfig, herdrConfigTarget }),
|
|
970
1308
|
...(herdrConfigProblem === undefined ? {} : { herdrConfigProblem }),
|
|
1309
|
+
...(herdrConfigWarning === undefined ? {} : { herdrConfigWarning }),
|
|
1310
|
+
...(herdrEnv === undefined ? {} : { herdrEnv, herdrEnvTarget }),
|
|
971
1311
|
recoverUnit,
|
|
972
1312
|
recoverScript,
|
|
973
1313
|
...(project === undefined
|
|
@@ -982,7 +1322,25 @@ export function planHostRuntime(
|
|
|
982
1322
|
: {
|
|
983
1323
|
briefLink: planBriefLink(project),
|
|
984
1324
|
...(project.escalation.orchestrator === "external"
|
|
985
|
-
?
|
|
1325
|
+
? (() => {
|
|
1326
|
+
const planned = planTick(project, telegramStateDir);
|
|
1327
|
+
return {
|
|
1328
|
+
...(planned.previousAgentName === undefined || runtime.herdr === undefined
|
|
1329
|
+
? {}
|
|
1330
|
+
: {
|
|
1331
|
+
// The restamp that made the tick's agentName no longer
|
|
1332
|
+
// the shared default: the live pane (if any) still
|
|
1333
|
+
// carries the old name, and setup will rename it after
|
|
1334
|
+
// the install — or print the exact command.
|
|
1335
|
+
agentRename: {
|
|
1336
|
+
session: runtime.herdrSession ?? DEFAULT_HERDR_SESSION,
|
|
1337
|
+
from: planned.previousAgentName,
|
|
1338
|
+
to: project.name,
|
|
1339
|
+
},
|
|
1340
|
+
}),
|
|
1341
|
+
tick: planned.write,
|
|
1342
|
+
};
|
|
1343
|
+
})()
|
|
986
1344
|
: {}),
|
|
987
1345
|
}),
|
|
988
1346
|
steps: installSteps,
|
|
@@ -1009,6 +1367,13 @@ export function formatHostRuntimePlan(plan: HostRuntimePlan): string {
|
|
|
1009
1367
|
...(plan.herdrConfig === undefined
|
|
1010
1368
|
? [` pane shell skipped — ${plan.herdrConfigProblem ?? "login shell unusable"}`]
|
|
1011
1369
|
: [` pane shell ${plan.herdrConfig.action} ${plan.herdrConfig.path} ([terminal] default_shell)`]),
|
|
1370
|
+
...(plan.herdrConfigWarning === undefined ? [] : [` pane resume warn — ${plan.herdrConfigWarning}`]),
|
|
1371
|
+
...(plan.herdrEnv === undefined
|
|
1372
|
+
? []
|
|
1373
|
+
: [` herdr env ${plan.herdrEnv.action} ${plan.herdrEnv.path} -> ${plan.herdrEnvTarget}`]),
|
|
1374
|
+
...(plan.agentRename === undefined
|
|
1375
|
+
? []
|
|
1376
|
+
: [` agent rename ${plan.agentRename.from} -> ${plan.agentRename.to} in session ${plan.agentRename.session}`]),
|
|
1012
1377
|
]),
|
|
1013
1378
|
];
|
|
1014
1379
|
if (plan.tick !== undefined) {
|
|
@@ -1075,6 +1440,10 @@ export function writeHostRuntime(plan: HostRuntimePlan): HostRuntimeWrite {
|
|
|
1075
1440
|
atomicWrite(plan.herdrConfig.path, plan.herdrConfig.content, 0o644);
|
|
1076
1441
|
wrote.push(plan.herdrConfig.path);
|
|
1077
1442
|
}
|
|
1443
|
+
if (plan.herdrEnv !== undefined && plan.herdrEnv.action !== "keep") {
|
|
1444
|
+
atomicWrite(plan.herdrEnv.path, plan.herdrEnv.content, 0o644);
|
|
1445
|
+
wrote.push(plan.herdrEnv.path);
|
|
1446
|
+
}
|
|
1078
1447
|
if (plan.recoverUnit.action !== "keep") {
|
|
1079
1448
|
atomicWrite(plan.recoverUnit.path, plan.recoverUnit.content, 0o644);
|
|
1080
1449
|
wrote.push(plan.recoverUnit.path);
|
package/src/setup-install.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
19
19
|
import { platform } from "node:os";
|
|
20
20
|
import { join } from "node:path";
|
|
21
21
|
import { loadConfig, stateDir } from "./config.ts";
|
|
22
|
-
import { pauseInstance, setPaused, statusSnapshot } from "./daemon.ts";
|
|
22
|
+
import { pauseInstance, pauseSourceToken, setPaused, statusSnapshot } from "./daemon.ts";
|
|
23
23
|
import { fleetLayers, DEFAULT_HERDR_UNIT } from "./fleet.ts";
|
|
24
24
|
import { livingDaemon } from "./lifecycle.ts";
|
|
25
25
|
import { dbPath, openStore } from "./store.ts";
|
|
@@ -44,13 +44,16 @@ import {
|
|
|
44
44
|
import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
|
|
45
45
|
import { runPrivileged, type PrivilegedDeps, type PrivilegedStep } from "./privileged.ts";
|
|
46
46
|
import {
|
|
47
|
+
agentRenameVerdict,
|
|
47
48
|
checkEscalation,
|
|
49
|
+
DEFAULT_AGENT_RENAME_DEPS,
|
|
48
50
|
planHostRuntime,
|
|
49
51
|
totalConfiguredWorkers,
|
|
50
52
|
writeHostRuntime,
|
|
51
53
|
STAGED_SERVICE_NAME,
|
|
52
54
|
SYSTEMD_UNIT_DIR,
|
|
53
55
|
RECOVER_SCRIPT_INSTALL_PATH,
|
|
56
|
+
type AgentRenameDeps,
|
|
54
57
|
type EscalationDeps,
|
|
55
58
|
type ServiceRuntime,
|
|
56
59
|
} from "./setup-host.ts";
|
|
@@ -108,6 +111,12 @@ export interface InstallDeps {
|
|
|
108
111
|
* attributed before the session host restarts under them. */
|
|
109
112
|
drainStore?: Pick<Store, "liveRuns" | "updateRun">;
|
|
110
113
|
};
|
|
114
|
+
/**
|
|
115
|
+
* The live herdr CLI seam for the post-install agent rename (#541).
|
|
116
|
+
* Injectable so tests pin the verdict without touching the host's herdr; the
|
|
117
|
+
* default is the same `herdr --session` CLI recover.sh and the tick use.
|
|
118
|
+
*/
|
|
119
|
+
agentRename?: AgentRenameDeps;
|
|
111
120
|
}
|
|
112
121
|
|
|
113
122
|
/**
|
|
@@ -185,6 +194,7 @@ export async function runHostInstall(
|
|
|
185
194
|
// No pane-shell key is planned (unusable login shell, unparseable config):
|
|
186
195
|
// the operator hears why before the consent prompt, not after an install
|
|
187
196
|
// that quietly had nothing to say about the shell.
|
|
197
|
+
if (plan.herdrConfigWarning !== undefined) ui.notify(plan.herdrConfigWarning, "warning");
|
|
188
198
|
if (plan.herdrConfigProblem !== undefined) ui.notify(plan.herdrConfigProblem, "warning");
|
|
189
199
|
// A host-global install (no --project): the per-project tail was
|
|
190
200
|
// deliberately not written, and the operator must be told which files and
|
|
@@ -299,6 +309,34 @@ export async function runHostInstall(
|
|
|
299
309
|
}
|
|
300
310
|
if (fence !== undefined && !fence.initialPaused) drainDeps.setPaused(false, fence.scope.pauseKey);
|
|
301
311
|
ui.notify(`Installed and started ${STAGED_SERVICE_NAME}.`, "info");
|
|
312
|
+
// #541: the tick plan may have restamped agentName away from the shared
|
|
313
|
+
// default — the exact moment the live pane is stuck under the old name and
|
|
314
|
+
// every tick declines with the rename hint. Perform the rename now that the
|
|
315
|
+
// herdr session has (re)started, safely: exactly one pane carrying the old
|
|
316
|
+
// identity is renamed; zero, several, or an unreadable agent list prints the
|
|
317
|
+
// exact command and says why it was not run. Never guesses a pane.
|
|
318
|
+
if (plan.agentRename !== undefined) {
|
|
319
|
+
const renameDeps = deps.agentRename ?? DEFAULT_AGENT_RENAME_DEPS;
|
|
320
|
+
const verdict = agentRenameVerdict(
|
|
321
|
+
renameDeps.list(plan.agentRename.session),
|
|
322
|
+
plan.agentRename.from,
|
|
323
|
+
plan.agentRename.to,
|
|
324
|
+
plan.agentRename.session,
|
|
325
|
+
);
|
|
326
|
+
if (verdict.kind === "rename") {
|
|
327
|
+
const ran = renameDeps.rename(plan.agentRename.session, verdict.pane, plan.agentRename.to);
|
|
328
|
+
if (ran.ok) {
|
|
329
|
+
ui.notify(`Renamed the herdr agent in pane ${verdict.pane} to ${plan.agentRename.to}.`, "info");
|
|
330
|
+
} else {
|
|
331
|
+
ui.notify(
|
|
332
|
+
`The herdr agent rename did not go through (${ran.problem ?? "unknown"}): ${verdict.command} — the pane still carries the old name.`,
|
|
333
|
+
"warning",
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
} else {
|
|
337
|
+
ui.notify(`${verdict.reason}; run it by hand: ${verdict.command}`, "warning");
|
|
338
|
+
}
|
|
339
|
+
}
|
|
302
340
|
return { kind: "installed", wrote };
|
|
303
341
|
}
|
|
304
342
|
|
|
@@ -321,7 +359,7 @@ function hostInstallDrainDeps(ui: WizardUi, deps: InstallDeps): DrainDeps {
|
|
|
321
359
|
: { running: true, project: daemon.project, generation: `${daemon.pid}@${daemon.startedAt}` };
|
|
322
360
|
},
|
|
323
361
|
pauseState: (project) => pauseInstance(project),
|
|
324
|
-
setPaused: (v, project) => setPaused(v, { source: "setup host", reason: "setup host, draining" }, project),
|
|
362
|
+
setPaused: (v, project) => setPaused(v, { source: pauseSourceToken("setup host"), reason: "setup host, draining" }, project),
|
|
325
363
|
sleep: Bun.sleep,
|
|
326
364
|
log: (message) => ui.notify(message, "info"),
|
|
327
365
|
};
|