@songsid/agend 2.1.8 → 2.1.10-beta.1
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 +8 -3
- package/README.zh-TW.md +11 -5
- package/dist/backend/claude-code.d.ts +35 -5
- package/dist/backend/claude-code.js +288 -17
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex.d.ts +13 -0
- package/dist/backend/codex.js +74 -2
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/credential-profile.d.ts +9 -0
- package/dist/backend/credential-profile.js +20 -0
- package/dist/backend/credential-profile.js.map +1 -1
- package/dist/backend/grok.js +19 -0
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/kiro.d.ts +0 -2
- package/dist/backend/kiro.js +8 -7
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/muse.d.ts +6 -0
- package/dist/backend/muse.js +7 -1
- package/dist/backend/muse.js.map +1 -1
- package/dist/backend/types.d.ts +1 -1
- package/dist/channel/adapters/telegram.js +4 -3
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/mcp-operation-response.js +5 -4
- package/dist/channel/mcp-operation-response.js.map +1 -1
- package/dist/channel/mcp-server.js +9 -0
- package/dist/channel/mcp-server.js.map +1 -1
- package/dist/channel/mcp-tools.js +1 -1
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/types.d.ts +7 -0
- package/dist/cli.js +150 -52
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +24 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.js +27 -1
- package/dist/config.js.map +1 -1
- package/dist/cross-instance-envelope.d.ts +7 -0
- package/dist/cross-instance-envelope.js +19 -1
- package/dist/cross-instance-envelope.js.map +1 -1
- package/dist/daemon.d.ts +112 -3
- package/dist/daemon.js +360 -41
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +9 -1
- package/dist/delivery-outbox.js +43 -27
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/fleet-manager.d.ts +133 -5
- package/dist/fleet-manager.js +525 -86
- package/dist/fleet-manager.js.map +1 -1
- package/dist/forged-envelope-scan.d.ts +160 -0
- package/dist/forged-envelope-scan.js +309 -0
- package/dist/forged-envelope-scan.js.map +1 -0
- package/dist/general-knowledge/skills/persona-emoji/SKILL.md +5 -2
- package/dist/instance-lifecycle.d.ts +90 -6
- package/dist/instance-lifecycle.js +238 -30
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +180 -8
- package/dist/locale.js.map +1 -1
- package/dist/login-controller.d.ts +38 -0
- package/dist/login-controller.js +236 -8
- package/dist/login-controller.js.map +1 -1
- package/dist/login-flows.d.ts +25 -1
- package/dist/login-flows.js +10 -2
- package/dist/login-flows.js.map +1 -1
- package/dist/ls-rows.d.ts +17 -0
- package/dist/ls-rows.js +73 -0
- package/dist/ls-rows.js.map +1 -1
- package/dist/outbound-handlers.d.ts +3 -0
- package/dist/outbound-handlers.js +17 -19
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +2 -0
- package/dist/outbound-schemas.js +1 -1
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/paths.js +4 -2
- package/dist/paths.js.map +1 -1
- package/dist/pause-marker.d.ts +10 -1
- package/dist/pause-marker.js +26 -2
- package/dist/pause-marker.js.map +1 -1
- package/dist/routing-engine.d.ts +17 -2
- package/dist/routing-engine.js +48 -5
- package/dist/routing-engine.js.map +1 -1
- package/dist/service-installer.d.ts +8 -0
- package/dist/service-installer.js +36 -1
- package/dist/service-installer.js.map +1 -1
- package/dist/settings-api.d.ts +2 -0
- package/dist/settings-api.js +3 -1
- package/dist/settings-api.js.map +1 -1
- package/dist/setup-wizard.js +3 -1
- package/dist/setup-wizard.js.map +1 -1
- package/dist/status-emojis.d.ts +9 -1
- package/dist/status-emojis.js +11 -1
- package/dist/status-emojis.js.map +1 -1
- package/dist/target-queue-worker.d.ts +67 -0
- package/dist/target-queue-worker.js +76 -0
- package/dist/target-queue-worker.js.map +1 -0
- package/dist/tool-permissions.js +7 -2
- package/dist/tool-permissions.js.map +1 -1
- package/dist/topic-commands.js +23 -19
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-sources.d.ts +30 -0
- package/dist/transcript-sources.js +78 -0
- package/dist/transcript-sources.js.map +1 -1
- package/dist/tunnel/cloudflared.d.ts +23 -5
- package/dist/tunnel/cloudflared.js +32 -13
- package/dist/tunnel/cloudflared.js.map +1 -1
- package/dist/tunnel/manager.d.ts +6 -0
- package/dist/tunnel/manager.js +60 -22
- package/dist/tunnel/manager.js.map +1 -1
- package/dist/tunnel/public-fetch.d.ts +30 -0
- package/dist/tunnel/public-fetch.js +156 -0
- package/dist/tunnel/public-fetch.js.map +1 -0
- package/dist/tunnel/types.d.ts +8 -2
- package/dist/tunnel/types.js +8 -2
- package/dist/tunnel/types.js.map +1 -1
- package/dist/types.d.ts +50 -1
- package/dist/types.js +9 -1
- package/dist/types.js.map +1 -1
- package/dist/ui/settings.html +2 -2
- package/dist/ui/view.html +14 -0
- package/dist/ui/web-terminal/terminal.html +1 -0
- package/dist/wake-coordinator.d.ts +128 -0
- package/dist/wake-coordinator.js +337 -0
- package/dist/wake-coordinator.js.map +1 -0
- package/dist/web-api.d.ts +3 -1
- package/dist/web-api.js +1 -1
- package/dist/web-api.js.map +1 -1
- package/dist/web-host-guard.d.ts +47 -0
- package/dist/web-host-guard.js +75 -0
- package/dist/web-host-guard.js.map +1 -0
- package/dist/web-terminal-http.d.ts +34 -0
- package/dist/web-terminal-http.js +63 -0
- package/dist/web-terminal-http.js.map +1 -1
- package/dist/web-terminal.d.ts +2 -0
- package/dist/web-terminal.js +3 -6
- package/dist/web-terminal.js.map +1 -1
- package/package.json +1 -1
- package/templates/systemd.service.ejs +7 -0
|
@@ -9,7 +9,7 @@ import { isModelCompatible } from "./backend/types.js";
|
|
|
9
9
|
import { safeHandler } from "./safe-async.js";
|
|
10
10
|
import { t } from "./locale.js";
|
|
11
11
|
import { EMBEDDED_CREDENTIAL_REMOTE_REPAIR_FAILED, EMBEDDED_CREDENTIAL_REMOTE_WARNING, secureGitHubRemotesForWorktree, } from "./remote-credential-check.js";
|
|
12
|
-
import { clearPausedMarker, hasPausedMarker, readPausedAt, writePausedMarker } from "./pause-marker.js";
|
|
12
|
+
import { clearPausedMarker, hasPausedMarker, readPausedAt, readPauseReason, writePausedMarker } from "./pause-marker.js";
|
|
13
13
|
import { reportProviderRateLimit } from "./usage/provider-alerts.js";
|
|
14
14
|
import { isFleetStartCommandLine } from "./fleet-lock.js";
|
|
15
15
|
import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
|
|
@@ -18,18 +18,37 @@ import { fetchClaudeUsage, fetchCodexUsage } from "./usage/providers.js";
|
|
|
18
18
|
import { assertExplicitInstanceRemoval, authorizeExplicitInstanceRemoval } from "./instance-removal.js";
|
|
19
19
|
import { capturePendingTargetReconciliation, finishTargetReconciliation, reconcileTargetBeforeStart, RECONCILIATION_WRITE_DRAIN_TIMEOUT_MS, retireCapturedTargetWindows, } from "./delivery-reconciliation.js";
|
|
20
20
|
export { isFleetStartCommandLine } from "./fleet-lock.js";
|
|
21
|
+
const localBin = (home) => join(home, ".local", "bin");
|
|
21
22
|
/** Shared CLI metadata used by startup validation and ClassicBot onboarding. */
|
|
22
23
|
export const BACKEND_INSTALLATION_INFO = {
|
|
23
|
-
"claude-code": { binary: "claude", install: "curl -fsSL https://claude.ai/install.sh | bash" },
|
|
24
|
+
"claude-code": { binary: "claude", install: "curl -fsSL https://claude.ai/install.sh | bash", binDirs: (_env, home) => [localBin(home)] },
|
|
24
25
|
"gemini-cli": { binary: "gemini", install: "npm i -g @google/gemini-cli" },
|
|
25
26
|
// The curl installer serves Linux and macOS (verified: 200, text/x-shellscript);
|
|
26
27
|
// the previous `brew install --cask` form only worked on macOS.
|
|
27
|
-
"kiro-cli": { binary: "kiro-cli", install: "curl -fsSL https://cli.kiro.dev/install | bash" },
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
"kiro-cli": { binary: "kiro-cli", install: "curl -fsSL https://cli.kiro.dev/install | bash", binDirs: (_env, home) => [localBin(home)] },
|
|
29
|
+
// install.sh: BIN_DIR="${CODEX_INSTALL_DIR:-$HOME/.local/bin}"; codex there is
|
|
30
|
+
// a symlink into ~/.codex/packages/standalone/current/bin (#1092).
|
|
31
|
+
codex: {
|
|
32
|
+
binary: "codex", install: "curl -fsSL https://chatgpt.com/codex/install.sh | sh",
|
|
33
|
+
binDirs: (env, home) => [env.CODEX_INSTALL_DIR, localBin(home)].filter((d) => !!d),
|
|
34
|
+
},
|
|
35
|
+
opencode: { binary: "opencode", install: "curl -fsSL https://opencode.ai/install | bash", binDirs: (_env, home) => [join(home, ".opencode", "bin")] },
|
|
36
|
+
antigravity: { binary: "agy", install: "curl -fsSL https://antigravity.google/cli/install.sh | bash", binDirs: (_env, home) => [localBin(home)] },
|
|
37
|
+
grok: {
|
|
38
|
+
binary: "grok", install: "curl -fsSL https://x.ai/cli/install.sh | bash",
|
|
39
|
+
binDirs: (env, home) => [env.GROK_BIN_DIR, join(home, ".grok", "bin")].filter((d) => !!d),
|
|
40
|
+
},
|
|
41
|
+
// #1092: the muse launcher keeps its binary NEXT TO ITSELF. Piped into bash
|
|
42
|
+
// it has no file of its own, so it downloaded the binary into the current
|
|
43
|
+
// directory and then started muse there, leaving no `muse` command anywhere.
|
|
44
|
+
// Its install mode (MUSE_LAUNCHER_INSTALL=1, read from the launcher) is run
|
|
45
|
+
// on the launcher saved as ~/.local/bin/muse: it downloads beside it and
|
|
46
|
+
// exits without launching.
|
|
47
|
+
muse: {
|
|
48
|
+
binary: "muse",
|
|
49
|
+
install: 'mkdir -p "$HOME/.local/bin" && curl -fsSL https://api.meta.ai/muse-launcher.sh -o "$HOME/.local/bin/muse" && chmod +x "$HOME/.local/bin/muse" && MUSE_LAUNCHER_INSTALL=1 "$HOME/.local/bin/muse"',
|
|
50
|
+
binDirs: (_env, home) => [localBin(home)],
|
|
51
|
+
},
|
|
33
52
|
};
|
|
34
53
|
/** Check one executable using the same PATH visible to the fleet process. */
|
|
35
54
|
export function checkBinaryInstalled(binary) {
|
|
@@ -83,6 +102,19 @@ export function getUnsafeInstanceDaemonPidReason(pid, dataDir) {
|
|
|
83
102
|
}
|
|
84
103
|
return null;
|
|
85
104
|
}
|
|
105
|
+
/** Thrown by a start whose generation was invalidated (stop/restart) before it could publish. */
|
|
106
|
+
export class SupersededStartError extends Error {
|
|
107
|
+
constructor(name) {
|
|
108
|
+
super(`Start of '${name}' was superseded by a stop or restart before it registered`);
|
|
109
|
+
this.name = "SupersededStartError";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* How long a target just woken for queued work is held from idle auto-pause
|
|
114
|
+
* before its first queued delivery is claimed (Phase 2 design §1.6): the
|
|
115
|
+
* cross-instance idle gate (60s) plus a 60s grace.
|
|
116
|
+
*/
|
|
117
|
+
export const PRECLAIM_LEASE_MS = 120_000;
|
|
86
118
|
/**
|
|
87
119
|
* Working directories of the fleet's other instances on `backendName` (#984):
|
|
88
120
|
* Codex consults them only to decide whether `resume --last` could take a
|
|
@@ -194,6 +226,21 @@ export class InstanceLifecycle {
|
|
|
194
226
|
ctx;
|
|
195
227
|
/** Active daemon processes: instanceName → Daemon */
|
|
196
228
|
daemons = new Map();
|
|
229
|
+
/**
|
|
230
|
+
* Phase 2a: one transition (start / stop / wake / restart) at a time per
|
|
231
|
+
* instance. A call that passes the current transition's handle runs inline;
|
|
232
|
+
* anything else queues.
|
|
233
|
+
*/
|
|
234
|
+
transitionTails = new Map();
|
|
235
|
+
activeTransition = new Map();
|
|
236
|
+
/** Bumped synchronously by stop/restart: a start begun under an older epoch must not publish. */
|
|
237
|
+
lifecycleEpochs = new Map();
|
|
238
|
+
/** Single wake flight per instance: concurrent wakes share it. */
|
|
239
|
+
wakeFlights = new Map();
|
|
240
|
+
/** Starts that begin their idle window now instead of at the persisted last inbound. */
|
|
241
|
+
activitySeedNow = new Set();
|
|
242
|
+
/** When each instance was last woken or started for work — the pre-claim lease clock. */
|
|
243
|
+
wokeAt = new Map();
|
|
197
244
|
/** backend → last auth-error alert time, so one expiry sends one alert. */
|
|
198
245
|
lastAuthAlertAt = new Map();
|
|
199
246
|
/**
|
|
@@ -610,6 +657,10 @@ export class InstanceLifecycle {
|
|
|
610
657
|
// restore its tools. With mcp_auto_restart (default) the daemon requests an
|
|
611
658
|
// idle-gated restart itself — an immediate one would interrupt whatever the
|
|
612
659
|
// agent is doing. With it off, tell the operator what to run, as before.
|
|
660
|
+
if (data.neverConnected) {
|
|
661
|
+
incident.deathNoticeSent = this.notifyIncident(name, "mcp_died", t(data.autoRestart ? "inst.mcp_never_connected_auto" : "inst.mcp_never_connected_manual", name));
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
613
664
|
incident.deathNoticeSent = this.notifyIncident(name, "mcp_died", `⚠️ \`${name}\` 的 MCP server 已終止 — 這個 instance 目前無法使用 agend 工具(無法 reply / 跨 instance 通訊)。\n`
|
|
614
665
|
+ (data.autoRestart
|
|
615
666
|
? "CLI 本身還在執行;等它閒置後會自動重啟以恢復工具(進行中的工作不會被打斷,session 會保留)。"
|
|
@@ -628,7 +679,7 @@ export class InstanceLifecycle {
|
|
|
628
679
|
return;
|
|
629
680
|
}
|
|
630
681
|
incident.deathNoticeSent = false;
|
|
631
|
-
this.notifyIncident(name, "mcp_recovered", t("inst.mcp_recovered", name));
|
|
682
|
+
this.notifyIncident(name, "mcp_recovered", t(data.source === "late_connect" ? "inst.mcp_connected_late" : "inst.mcp_recovered", name));
|
|
632
683
|
}, this.ctx.logger, `daemon.mcp_recovered[${name}]`));
|
|
633
684
|
daemon.on("mcp_proxy_reply", safeHandler((data) => {
|
|
634
685
|
// The message itself goes out through the daemon's fleet_outbound path;
|
|
@@ -792,8 +843,9 @@ export class InstanceLifecycle {
|
|
|
792
843
|
// Pattern-matched auth errors get a second opinion from the real CLI
|
|
793
844
|
// before any pause/alert: an agent quoting "401 Unauthorized" in prose
|
|
794
845
|
// must not pause the fleet. A working credential ends the incident here.
|
|
846
|
+
let authVerdict;
|
|
795
847
|
if (data.type === "auth_error") {
|
|
796
|
-
const verdict = await this.verifyAuthError(name);
|
|
848
|
+
const verdict = authVerdict = await this.verifyAuthError(name);
|
|
797
849
|
if (verdict === "valid") {
|
|
798
850
|
this.ctx.logger.info({ name, backend: this.backendOf(name) }, "auth-error pattern ignored — token-free auth check passed (likely conversation text)");
|
|
799
851
|
// Dropping the incident is not enough: the daemon armed its auth
|
|
@@ -887,10 +939,10 @@ export class InstanceLifecycle {
|
|
|
887
939
|
const notificationTarget = this.ptyErrorNotificationTarget(name);
|
|
888
940
|
if (notificationTarget)
|
|
889
941
|
this.notifyIncident(notificationTarget, "pty_error", t("inst.codex_capacity_backoff_exhausted", name, String(attempts)));
|
|
890
|
-
void this.pause(name)
|
|
942
|
+
void this.pause(name, "error")
|
|
891
943
|
.catch(err => this.ctx.logger.warn({ err, name }, "capacity-exhausted pause failed"))
|
|
892
944
|
.finally(() => { if (!this.isPaused(name))
|
|
893
|
-
this.daemons.get(name)?.requestPauseWhenIdle(); });
|
|
945
|
+
this.daemons.get(name)?.requestPauseWhenIdle({ reason: "error" }); });
|
|
894
946
|
}
|
|
895
947
|
else {
|
|
896
948
|
const delays = InstanceLifecycle.CAPACITY_BACKOFF_DELAYS_MS;
|
|
@@ -933,14 +985,96 @@ export class InstanceLifecycle {
|
|
|
933
985
|
// credit and lost work. pause() alone no-ops while the pane is busy (the
|
|
934
986
|
// usual state when auth fails), so mark it to pause as soon as it idles.
|
|
935
987
|
// Queued messages survive: delivery wakes a paused instance.
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
988
|
+
if (data.type === "auth_error" && authVerdict === "unknown") {
|
|
989
|
+
// Nothing vouched for an auth hit the CLI could not be asked about
|
|
990
|
+
// (#1044): pause only if it is still on screen when the turn ends.
|
|
991
|
+
// Never the direct pause here — on an already-idle pane it would
|
|
992
|
+
// /quit before anything looked, and the check would never run. The
|
|
993
|
+
// daemon evaluates at once, so an idle pane is decided now.
|
|
994
|
+
daemon.requestPauseWhenIdle({ reconfirmAuth: true });
|
|
995
|
+
}
|
|
996
|
+
else {
|
|
997
|
+
// The marker must say auth (2b never auto-wakes a login failure); any
|
|
998
|
+
// other pause-action error is an error pause, not an operator one.
|
|
999
|
+
const reason = data.type === "auth_error" ? "auth" : "error";
|
|
1000
|
+
void this.pause(name, reason)
|
|
1001
|
+
.catch(err => this.ctx.logger.warn({ err, name }, "auth-error pause failed"))
|
|
1002
|
+
.finally(() => { if (!this.isPaused(name))
|
|
1003
|
+
daemon.requestPauseWhenIdle({ reason }); });
|
|
1004
|
+
}
|
|
940
1005
|
}
|
|
941
1006
|
}, this.ctx.logger, `daemon.pty_error[${name}]`));
|
|
942
1007
|
}
|
|
943
|
-
|
|
1008
|
+
epochOf(name) {
|
|
1009
|
+
return this.lifecycleEpochs.get(name) ?? 0;
|
|
1010
|
+
}
|
|
1011
|
+
/** Invalidate every start/wake already running for `name` (they will not publish). */
|
|
1012
|
+
invalidate(name) {
|
|
1013
|
+
this.lifecycleEpochs.set(name, this.epochOf(name) + 1);
|
|
1014
|
+
}
|
|
1015
|
+
/** Whether `transition` is `name`'s transition and is the one running now. */
|
|
1016
|
+
holds(name, transition) {
|
|
1017
|
+
return transition !== undefined && transition.name === name && this.activeTransition.get(name) === transition.token;
|
|
1018
|
+
}
|
|
1019
|
+
/** Run `fn` as `name`'s next transition, or inline when `parent` is the transition running now. */
|
|
1020
|
+
async runTransition(name, fn, parent) {
|
|
1021
|
+
if (this.holds(name, parent))
|
|
1022
|
+
return fn(parent);
|
|
1023
|
+
const previous = this.transitionTails.get(name) ?? Promise.resolve();
|
|
1024
|
+
const handle = { name, token: Symbol(name) };
|
|
1025
|
+
const run = previous.then(() => {
|
|
1026
|
+
this.activeTransition.set(name, handle.token);
|
|
1027
|
+
return fn(handle);
|
|
1028
|
+
}).finally(() => {
|
|
1029
|
+
if (this.activeTransition.get(name) === handle.token)
|
|
1030
|
+
this.activeTransition.delete(name);
|
|
1031
|
+
});
|
|
1032
|
+
const tail = run.then(() => { }, () => { });
|
|
1033
|
+
this.transitionTails.set(name, tail);
|
|
1034
|
+
try {
|
|
1035
|
+
return await run;
|
|
1036
|
+
}
|
|
1037
|
+
finally {
|
|
1038
|
+
if (this.transitionTails.get(name) === tail)
|
|
1039
|
+
this.transitionTails.delete(name);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
/** The next start of `name` is a wake/restart/explicit start: begin its idle window now. */
|
|
1043
|
+
markActivitySeedNow(name) {
|
|
1044
|
+
this.activitySeedNow.add(name);
|
|
1045
|
+
}
|
|
1046
|
+
/** Drop a seed request that no start consumed (the start returned early or failed before it). */
|
|
1047
|
+
clearActivitySeedNow(name) {
|
|
1048
|
+
this.activitySeedNow.delete(name);
|
|
1049
|
+
}
|
|
1050
|
+
/** Record that `name` was just woken or (re)started, opening its pre-claim lease window. */
|
|
1051
|
+
noteWokeForWork(name, now = Date.now()) {
|
|
1052
|
+
this.wokeAt.set(name, now);
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Phase 2a work lease (design §1.6). Held while a durable submission to the
|
|
1056
|
+
* target is in flight (unbounded: no timer releases a writer), or for
|
|
1057
|
+
* PRECLAIM_LEASE_MS after a wake/start while queued work is still waiting to
|
|
1058
|
+
* be claimed. It holds only the idle-timeout pause and warm-cap eviction —
|
|
1059
|
+
* never an auth-deferred or operator pause.
|
|
1060
|
+
*/
|
|
1061
|
+
hasWorkLease(name, now = Date.now()) {
|
|
1062
|
+
const outbox = this.ctx.deliveryOutbox;
|
|
1063
|
+
if (!outbox)
|
|
1064
|
+
return false;
|
|
1065
|
+
if (outbox.countForTarget(name, ["delivering", "submission_started"]) > 0)
|
|
1066
|
+
return true;
|
|
1067
|
+
const woke = this.wokeAt.get(name);
|
|
1068
|
+
if (woke === undefined || now - woke >= PRECLAIM_LEASE_MS)
|
|
1069
|
+
return false;
|
|
1070
|
+
return outbox.countForTarget(name, ["queued", "retry_wait"]) > 0;
|
|
1071
|
+
}
|
|
1072
|
+
async start(name, config, topicMode, runtimeIdentity, transition) {
|
|
1073
|
+
return this.runTransition(name, () => this.startInTransition(name, config, topicMode, runtimeIdentity), transition);
|
|
1074
|
+
}
|
|
1075
|
+
async startInTransition(name, config, topicMode, runtimeIdentity) {
|
|
1076
|
+
const epoch = this.epochOf(name);
|
|
1077
|
+
const seedNow = this.activitySeedNow.delete(name);
|
|
944
1078
|
if (this.daemons.has(name)) {
|
|
945
1079
|
this.ctx.logger.info({ name }, "Instance already running, skipping");
|
|
946
1080
|
return;
|
|
@@ -979,6 +1113,9 @@ export class InstanceLifecycle {
|
|
|
979
1113
|
model: config.model ?? "default",
|
|
980
1114
|
}, this.ctx.spawnGate, this.ctx.stormWindow, this.ctx.backendOutage);
|
|
981
1115
|
daemon.setDeliveryOutboxPort(this.ctx.deliveryOutbox ?? undefined);
|
|
1116
|
+
daemon.setWorkLeaseCheck(() => this.hasWorkLease(name));
|
|
1117
|
+
if (seedNow)
|
|
1118
|
+
daemon.seedActivityNow();
|
|
982
1119
|
// Read at each spawn so a fleet.yaml reload is honoured (#984).
|
|
983
1120
|
daemon.setPeerWorkingDirectories(() => peerWorkingDirectories(this.ctx.fleetConfig, name, backendName));
|
|
984
1121
|
daemon.setStatusEmojiAvoidList(() => this.ctx.statusEmojiAvoidList?.(name));
|
|
@@ -1019,19 +1156,37 @@ export class InstanceLifecycle {
|
|
|
1019
1156
|
this.ctx.logger.warn({ name, ...reconciled }, "Some durable submissions need operator reconciliation");
|
|
1020
1157
|
}
|
|
1021
1158
|
if (!reconciled.safeToStart) {
|
|
1022
|
-
const message =
|
|
1159
|
+
const message = t("delivery.restart_blocked", name);
|
|
1023
1160
|
this.ctx.logger.error({ name, ...reconciled }, message);
|
|
1024
1161
|
this.ctx.notifyFleetError?.(message);
|
|
1025
1162
|
await daemon.abortStartup().catch(err => this.ctx.logger.warn({ err, name }, "Failed to dispose daemon after reconciliation could not verify old CLI exit"));
|
|
1026
1163
|
throw new Error(message);
|
|
1027
1164
|
}
|
|
1028
1165
|
}
|
|
1166
|
+
if (this.epochOf(name) !== epoch) {
|
|
1167
|
+
// A stop/restart arrived while this start was reconciling: do not spawn.
|
|
1168
|
+
await daemon.abortStartup().catch(() => { });
|
|
1169
|
+
throw new SupersededStartError(name);
|
|
1170
|
+
}
|
|
1171
|
+
// The startup scan can raise an incident (sign-in screen, fatal modal)
|
|
1172
|
+
// before the handlers below exist and before this daemon is registered;
|
|
1173
|
+
// hold it and deliver it once both are true.
|
|
1174
|
+
daemon.holdStartupIncidents?.();
|
|
1029
1175
|
await InstanceLifecycle.startOrDispose(daemon, name, this.ctx.logger);
|
|
1176
|
+
// Publication fence (Phase 2a): the spawn cannot be cancelled, so a stop or
|
|
1177
|
+
// restart requested meanwhile has been waiting behind this transition. A
|
|
1178
|
+
// superseded start must not register, signal ready, or keep its window.
|
|
1179
|
+
if (this.epochOf(name) !== epoch) {
|
|
1180
|
+
await daemon.abortStartup().catch(err => this.ctx.logger.warn({ err, name }, "Failed to dispose a superseded start"));
|
|
1181
|
+
throw new SupersededStartError(name);
|
|
1182
|
+
}
|
|
1030
1183
|
this.capacityBackoffBaselines.delete(name);
|
|
1031
1184
|
this.daemons.set(name, daemon);
|
|
1185
|
+
if (seedNow)
|
|
1186
|
+
this.noteWokeForWork(name);
|
|
1032
1187
|
this.ctx.onDaemonReady?.(name, daemon.bootId);
|
|
1033
|
-
daemon.on("auto_pause_requested", safeHandler(async () => {
|
|
1034
|
-
await this.pause(name);
|
|
1188
|
+
daemon.on("auto_pause_requested", safeHandler(async (data) => {
|
|
1189
|
+
await this.pause(name, data?.reason ?? "idle");
|
|
1035
1190
|
}, this.ctx.logger, `autoPause[${name}]`));
|
|
1036
1191
|
daemon.on("auto_paused", (data) => {
|
|
1037
1192
|
this.ctx.eventLog?.insert(name, "instance_paused", { reason: "idle", paused_at: data.pausedAt });
|
|
@@ -1057,6 +1212,9 @@ export class InstanceLifecycle {
|
|
|
1057
1212
|
this.attachDeliveryStatusHandlers(name, daemon);
|
|
1058
1213
|
this.ctx.setTopicIcon(name, "green");
|
|
1059
1214
|
this.ctx.touchActivity(name);
|
|
1215
|
+
// Last: handlers attached and daemon registered, so a held startup incident
|
|
1216
|
+
// (pause, notify) finds both.
|
|
1217
|
+
daemon.releaseStartupIncidents?.();
|
|
1060
1218
|
}
|
|
1061
1219
|
isPaused(name) {
|
|
1062
1220
|
return this.daemons.get(name)?.isPaused ?? hasPausedMarker(this.ctx.getInstanceDir(name));
|
|
@@ -1064,7 +1222,7 @@ export class InstanceLifecycle {
|
|
|
1064
1222
|
getLastPausedAt(name) {
|
|
1065
1223
|
return this.daemons.get(name)?.lastPausedAt ?? readPausedAt(this.ctx.getInstanceDir(name));
|
|
1066
1224
|
}
|
|
1067
|
-
async pause(name) {
|
|
1225
|
+
async pause(name, reason = "operator") {
|
|
1068
1226
|
// Final backstop shared by slash, MCP, Settings, warm-cap, and any future
|
|
1069
1227
|
// caller. General is the coordinator and must remain resident to route work.
|
|
1070
1228
|
if (isGeneralInstance(this.ctx.fleetConfig, name)) {
|
|
@@ -1089,7 +1247,7 @@ export class InstanceLifecycle {
|
|
|
1089
1247
|
}
|
|
1090
1248
|
this.ctx.stopStatuslineWatcher(name);
|
|
1091
1249
|
try {
|
|
1092
|
-
await daemon.pause();
|
|
1250
|
+
await daemon.pause(reason);
|
|
1093
1251
|
}
|
|
1094
1252
|
finally {
|
|
1095
1253
|
// A rejected/no-op pause leaves the instance active and must not strand
|
|
@@ -1098,25 +1256,65 @@ export class InstanceLifecycle {
|
|
|
1098
1256
|
this.ctx.startStatuslineWatcher(name);
|
|
1099
1257
|
}
|
|
1100
1258
|
}
|
|
1101
|
-
|
|
1259
|
+
/** When `name` was last woken or (re)started for work, if recorded. */
|
|
1260
|
+
wokeAtFor(name) {
|
|
1261
|
+
return this.wokeAt.get(name);
|
|
1262
|
+
}
|
|
1263
|
+
async wake(name, timeoutMs = 30_000, transition, opts = {}) {
|
|
1264
|
+
const woke = await this.wakeInner(name, timeoutMs, transition);
|
|
1265
|
+
// Only a successful operator/user wake lifts the coordinator's park.
|
|
1266
|
+
if (opts.source !== "coordinator")
|
|
1267
|
+
this.ctx.onExternalWake?.(name);
|
|
1268
|
+
return woke;
|
|
1269
|
+
}
|
|
1270
|
+
async wakeInner(name, timeoutMs, transition) {
|
|
1271
|
+
if (this.holds(name, transition))
|
|
1272
|
+
return this.wakeInTransition(name, timeoutMs, transition);
|
|
1273
|
+
// An explicit wake that meets a restart joins it: the restart of a paused
|
|
1274
|
+
// instance is itself a wake (Phase 2a), and a second flight would race it.
|
|
1275
|
+
const restarting = this.ctx.restartInFlight?.(name);
|
|
1276
|
+
if (restarting) {
|
|
1277
|
+
await restarting;
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
const inFlight = this.wakeFlights.get(name);
|
|
1281
|
+
if (inFlight)
|
|
1282
|
+
return inFlight;
|
|
1283
|
+
const flight = this.runTransition(name, handle => this.wakeInTransition(name, timeoutMs, handle))
|
|
1284
|
+
.finally(() => { if (this.wakeFlights.get(name) === flight)
|
|
1285
|
+
this.wakeFlights.delete(name); });
|
|
1286
|
+
this.wakeFlights.set(name, flight);
|
|
1287
|
+
return flight;
|
|
1288
|
+
}
|
|
1289
|
+
async wakeInTransition(name, timeoutMs, transition) {
|
|
1290
|
+
const epoch = this.epochOf(name);
|
|
1102
1291
|
const daemon = this.daemons.get(name);
|
|
1103
1292
|
if (daemon) {
|
|
1104
1293
|
await daemon.wake(timeoutMs);
|
|
1294
|
+
// A stop/restart requested while the resident CLI was respawning is
|
|
1295
|
+
// queued behind this wake and will tear it down next: do not open a
|
|
1296
|
+
// pre-claim window or restart its watchers on the way out.
|
|
1297
|
+
if (this.epochOf(name) !== epoch)
|
|
1298
|
+
return;
|
|
1105
1299
|
}
|
|
1106
1300
|
else {
|
|
1107
1301
|
const instanceDir = this.ctx.getInstanceDir(name);
|
|
1108
1302
|
if (!hasPausedMarker(instanceDir))
|
|
1109
1303
|
throw new Error(`Cannot wake stopped instance '${name}'`);
|
|
1110
1304
|
const pausedAt = readPausedAt(instanceDir) ?? Date.now();
|
|
1305
|
+
const reason = readPauseReason(instanceDir);
|
|
1111
1306
|
clearPausedMarker(instanceDir);
|
|
1307
|
+
this.markActivitySeedNow(name);
|
|
1112
1308
|
try {
|
|
1113
|
-
await this.ctx.startPersistedPausedInstance(name);
|
|
1309
|
+
await this.ctx.startPersistedPausedInstance(name, transition);
|
|
1114
1310
|
}
|
|
1115
1311
|
catch (err) {
|
|
1116
|
-
|
|
1312
|
+
this.clearActivitySeedNow(name);
|
|
1313
|
+
writePausedMarker(instanceDir, pausedAt, reason);
|
|
1117
1314
|
throw err;
|
|
1118
1315
|
}
|
|
1119
1316
|
}
|
|
1317
|
+
this.noteWokeForWork(name);
|
|
1120
1318
|
this.ctx.startStatuslineWatcher(name);
|
|
1121
1319
|
}
|
|
1122
1320
|
/**
|
|
@@ -1141,7 +1339,16 @@ export class InstanceLifecycle {
|
|
|
1141
1339
|
await this.stop(name);
|
|
1142
1340
|
return true;
|
|
1143
1341
|
}
|
|
1144
|
-
async stop(name) {
|
|
1342
|
+
async stop(name, transition) {
|
|
1343
|
+
// Synchronously, before queueing: a start still spawning must see this and
|
|
1344
|
+
// not publish when it finishes (Phase 2a publication fence).
|
|
1345
|
+
if (!this.holds(name, transition))
|
|
1346
|
+
this.invalidate(name);
|
|
1347
|
+
return this.runTransition(name, () => this.stopInTransition(name), transition);
|
|
1348
|
+
}
|
|
1349
|
+
async stopInTransition(name) {
|
|
1350
|
+
this.invalidate(name);
|
|
1351
|
+
this.wokeAt.delete(name);
|
|
1145
1352
|
this.ctx.setTopicIcon(name, "remove");
|
|
1146
1353
|
// B1: cancel any pending capacity backoff timer so a stopped/removed
|
|
1147
1354
|
// instance is not restarted behind the user's back.
|
|
@@ -1336,9 +1543,10 @@ export class InstanceLifecycle {
|
|
|
1336
1543
|
await ipc.close();
|
|
1337
1544
|
this.ctx.instanceIpcClients.delete(name);
|
|
1338
1545
|
}
|
|
1339
|
-
// Remove from routing table
|
|
1546
|
+
// Remove from routing table (this instance only: #1085, another Telegram
|
|
1547
|
+
// world may own the same topic number)
|
|
1340
1548
|
if (config.topic_id != null) {
|
|
1341
|
-
this.ctx.routing.unregister(config.topic_id);
|
|
1549
|
+
this.ctx.routing.unregister(config.topic_id, name);
|
|
1342
1550
|
}
|
|
1343
1551
|
// Remove from fleet config and save
|
|
1344
1552
|
delete this.ctx.fleetConfig.instances[name];
|
|
@@ -1566,7 +1774,7 @@ export class InstanceLifecycle {
|
|
|
1566
1774
|
if (newInstanceName && this.ctx.fleetConfig?.instances[newInstanceName]) {
|
|
1567
1775
|
delete this.ctx.fleetConfig.instances[newInstanceName];
|
|
1568
1776
|
if (createdTopicId)
|
|
1569
|
-
this.ctx.routing.unregister(createdTopicId);
|
|
1777
|
+
this.ctx.routing.unregister(createdTopicId, newInstanceName);
|
|
1570
1778
|
this.ctx.saveFleetConfig();
|
|
1571
1779
|
}
|
|
1572
1780
|
if (createdTopicId) {
|
|
@@ -1648,7 +1856,7 @@ export class InstanceLifecycle {
|
|
|
1648
1856
|
}
|
|
1649
1857
|
// 4. Remove old config + routing (so new instance can reuse the name/topic)
|
|
1650
1858
|
if (topicId != null)
|
|
1651
|
-
this.ctx.routing.unregister(topicId);
|
|
1859
|
+
this.ctx.routing.unregister(topicId, instanceName);
|
|
1652
1860
|
delete this.ctx.fleetConfig.instances[instanceName];
|
|
1653
1861
|
this.ctx.saveFleetConfig();
|
|
1654
1862
|
// 5. Clean instanceDir to avoid stale rotation-state.json / crash-history
|