@songsid/agend 2.1.8-beta.1 → 2.1.8-beta.11
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/dist/agent-cli.js +14 -0
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.d.ts +4 -1
- package/dist/agent-endpoint.js +10 -1
- package/dist/agent-endpoint.js.map +1 -1
- 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/cli-env-probe-worker.d.ts +1 -0
- package/dist/backend/cli-env-probe-worker.js +25 -0
- package/dist/backend/cli-env-probe-worker.js.map +1 -0
- package/dist/backend/codex-session-lookup.d.ts +16 -0
- package/dist/backend/codex-session-lookup.js +76 -2
- package/dist/backend/codex-session-lookup.js.map +1 -1
- package/dist/backend/codex.d.ts +3 -4
- package/dist/backend/codex.js +44 -6
- 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/muse.d.ts +6 -0
- package/dist/backend/muse.js +37 -3
- package/dist/backend/muse.js.map +1 -1
- package/dist/backend/types.d.ts +13 -2
- package/dist/backend/types.js +14 -3
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +16 -4
- package/dist/channel/adapters/discord.js +20 -5
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/mcp-tools.js +6 -0
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/cli.js +138 -48
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +11 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.js +1 -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 +130 -6
- package/dist/daemon.js +486 -69
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +9 -1
- package/dist/delivery-outbox.js +25 -4
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/fleet-context.d.ts +5 -0
- package/dist/fleet-context.js.map +1 -1
- package/dist/fleet-manager.d.ts +165 -18
- package/dist/fleet-manager.js +821 -121
- package/dist/fleet-manager.js.map +1 -1
- package/dist/forged-envelope-scan.d.ts +160 -0
- package/dist/forged-envelope-scan.js +312 -0
- package/dist/forged-envelope-scan.js.map +1 -0
- package/dist/general-knowledge/skills/persona-emoji/SKILL.md +55 -0
- package/dist/instance-lifecycle.d.ts +81 -6
- package/dist/instance-lifecycle.js +201 -17
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +34 -4
- package/dist/locale.js.map +1 -1
- package/dist/login-controller.js +11 -4
- package/dist/login-controller.js.map +1 -1
- package/dist/login-flows.d.ts +18 -1
- package/dist/login-flows.js +9 -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 +3 -3
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +23 -4
- package/dist/outbound-schemas.js +12 -0
- 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/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 +8 -8
- package/dist/settings-api.js +12 -5
- package/dist/settings-api.js.map +1 -1
- package/dist/status-emojis.d.ts +28 -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 +15 -4
- package/dist/tool-permissions.js.map +1 -1
- package/dist/topic-commands.d.ts +9 -0
- package/dist/topic-commands.js +64 -21
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-monitor.js +3 -0
- package/dist/transcript-monitor.js.map +1 -1
- package/dist/transcript-sources.d.ts +63 -0
- package/dist/transcript-sources.js +171 -27
- package/dist/transcript-sources.js.map +1 -1
- package/dist/types.d.ts +26 -1
- package/dist/types.js +9 -1
- package/dist/types.js.map +1 -1
- package/dist/ui/settings.html +25 -13
- package/dist/wake-coordinator.d.ts +143 -0
- package/dist/wake-coordinator.js +321 -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-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
|
@@ -6,6 +6,7 @@ import type { EventLog } from "./event-log.js";
|
|
|
6
6
|
import type { TmuxControlClient } from "./tmux-control.js";
|
|
7
7
|
import type { FleetInstructionsParams } from "./instructions.js";
|
|
8
8
|
import type { DeliveryStatus } from "./status-emojis.js";
|
|
9
|
+
import { type PauseReason } from "./pause-marker.js";
|
|
9
10
|
import { type ProviderUsage } from "./usage/providers.js";
|
|
10
11
|
import type { SpawnGate } from "./spawn-gate.js";
|
|
11
12
|
import type { StormWindow } from "./storm-window.js";
|
|
@@ -113,8 +114,32 @@ export interface LifecycleContext {
|
|
|
113
114
|
finishDeliveryStatus?(instanceName: string, chatId: string, messageId: string, status: DeliveryStatus, threadId?: string): void;
|
|
114
115
|
/** #1005: the instance's own status emojis, for the instructions' "avoid these" line. */
|
|
115
116
|
statusEmojiAvoidList?(instanceName: string): string[];
|
|
116
|
-
startPersistedPausedInstance(name: string): Promise<void>;
|
|
117
|
+
startPersistedPausedInstance(name: string, transition?: TransitionHandle): Promise<void>;
|
|
118
|
+
/** The restart already in flight for `name`, if any — an explicit wake joins it (Phase 2a). */
|
|
119
|
+
restartInFlight?(name: string): Promise<void> | undefined;
|
|
120
|
+
/** Phase 2b: a wake that the wake coordinator did not start (operator, user message) succeeded. */
|
|
121
|
+
onExternalWake?(name: string): void;
|
|
117
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* Proof that the caller is running inside `name`'s current transition. Only
|
|
125
|
+
* direct internal calls hand it on (restart → stop → start, wake → marker-only
|
|
126
|
+
* start); a detached callback — an event handler, timer or setImmediate fired
|
|
127
|
+
* while a transition is still awaiting — has none and queues behind it.
|
|
128
|
+
*/
|
|
129
|
+
export interface TransitionHandle {
|
|
130
|
+
readonly name: string;
|
|
131
|
+
readonly token: symbol;
|
|
132
|
+
}
|
|
133
|
+
/** Thrown by a start whose generation was invalidated (stop/restart) before it could publish. */
|
|
134
|
+
export declare class SupersededStartError extends Error {
|
|
135
|
+
constructor(name: string);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* How long a target just woken for queued work is held from idle auto-pause
|
|
139
|
+
* before its first queued delivery is claimed (Phase 2 design §1.6): the
|
|
140
|
+
* cross-instance idle gate (60s) plus a 60s grace.
|
|
141
|
+
*/
|
|
142
|
+
export declare const PRECLAIM_LEASE_MS = 120000;
|
|
118
143
|
/**
|
|
119
144
|
* Working directories of the fleet's other instances on `backendName` (#984):
|
|
120
145
|
* Codex consults them only to decide whether `resume --last` could take a
|
|
@@ -124,7 +149,10 @@ export declare function peerWorkingDirectories(fleetConfig: FleetConfig | null,
|
|
|
124
149
|
/** What attachIncidentHandlers needs from a Daemon — the real one satisfies it. */
|
|
125
150
|
export interface IncidentEventSource {
|
|
126
151
|
on(event: string, handler: (...args: any[]) => void): unknown;
|
|
127
|
-
requestPauseWhenIdle(
|
|
152
|
+
requestPauseWhenIdle(opts?: {
|
|
153
|
+
reconfirmAuth?: boolean;
|
|
154
|
+
reason?: PauseReason;
|
|
155
|
+
}): void;
|
|
128
156
|
/** Re-checks that a Codex pane is live after an async quota probe. */
|
|
129
157
|
isCodexLivePane?(): Promise<boolean>;
|
|
130
158
|
/** Existing occurrence count used to preserve Codex capacity history on restart. */
|
|
@@ -133,6 +161,9 @@ export interface IncidentEventSource {
|
|
|
133
161
|
getHangDetector?(): {
|
|
134
162
|
on(event: string, handler: (...args: any[]) => void): unknown;
|
|
135
163
|
} | null;
|
|
164
|
+
/** Real daemons buffer `pty_error` raised by the startup scan until the lifecycle can handle it. */
|
|
165
|
+
holdStartupIncidents?(): void;
|
|
166
|
+
releaseStartupIncidents?(): void;
|
|
136
167
|
}
|
|
137
168
|
/** Arguments accepted by handleCreate — mirrors CreateInstanceArgs in outbound-schemas.ts
|
|
138
169
|
* plus internal-only fields forwarded by deploy_template (profile-derived). */
|
|
@@ -193,6 +224,21 @@ export declare class InstanceLifecycle {
|
|
|
193
224
|
private ctx;
|
|
194
225
|
/** Active daemon processes: instanceName → Daemon */
|
|
195
226
|
readonly daemons: Map<string, import("./daemon.js").Daemon>;
|
|
227
|
+
/**
|
|
228
|
+
* Phase 2a: one transition (start / stop / wake / restart) at a time per
|
|
229
|
+
* instance. A call that passes the current transition's handle runs inline;
|
|
230
|
+
* anything else queues.
|
|
231
|
+
*/
|
|
232
|
+
private transitionTails;
|
|
233
|
+
private activeTransition;
|
|
234
|
+
/** Bumped synchronously by stop/restart: a start begun under an older epoch must not publish. */
|
|
235
|
+
private lifecycleEpochs;
|
|
236
|
+
/** Single wake flight per instance: concurrent wakes share it. */
|
|
237
|
+
private wakeFlights;
|
|
238
|
+
/** Starts that begin their idle window now instead of at the persisted last inbound. */
|
|
239
|
+
private activitySeedNow;
|
|
240
|
+
/** When each instance was last woken or started for work — the pre-claim lease clock. */
|
|
241
|
+
private wokeAt;
|
|
196
242
|
/** backend → last auth-error alert time, so one expiry sends one alert. */
|
|
197
243
|
private lastAuthAlertAt;
|
|
198
244
|
/**
|
|
@@ -325,11 +371,39 @@ export declare class InstanceLifecycle {
|
|
|
325
371
|
*/
|
|
326
372
|
attachDeliveryStatusHandlers(name: string, daemon: IncidentEventSource): void;
|
|
327
373
|
attachIncidentHandlers(name: string, daemon: IncidentEventSource): void;
|
|
328
|
-
|
|
374
|
+
epochOf(name: string): number;
|
|
375
|
+
/** Invalidate every start/wake already running for `name` (they will not publish). */
|
|
376
|
+
invalidate(name: string): void;
|
|
377
|
+
/** Whether `transition` is `name`'s transition and is the one running now. */
|
|
378
|
+
holds(name: string, transition: TransitionHandle | undefined): transition is TransitionHandle;
|
|
379
|
+
/** Run `fn` as `name`'s next transition, or inline when `parent` is the transition running now. */
|
|
380
|
+
runTransition<T>(name: string, fn: (transition: TransitionHandle) => Promise<T>, parent?: TransitionHandle): Promise<T>;
|
|
381
|
+
/** The next start of `name` is a wake/restart/explicit start: begin its idle window now. */
|
|
382
|
+
markActivitySeedNow(name: string): void;
|
|
383
|
+
/** Drop a seed request that no start consumed (the start returned early or failed before it). */
|
|
384
|
+
clearActivitySeedNow(name: string): void;
|
|
385
|
+
/** Record that `name` was just woken or (re)started, opening its pre-claim lease window. */
|
|
386
|
+
noteWokeForWork(name: string, now?: number): void;
|
|
387
|
+
/**
|
|
388
|
+
* Phase 2a work lease (design §1.6). Held while a durable submission to the
|
|
389
|
+
* target is in flight (unbounded: no timer releases a writer), or for
|
|
390
|
+
* PRECLAIM_LEASE_MS after a wake/start while queued work is still waiting to
|
|
391
|
+
* be claimed. It holds only the idle-timeout pause and warm-cap eviction —
|
|
392
|
+
* never an auth-deferred or operator pause.
|
|
393
|
+
*/
|
|
394
|
+
hasWorkLease(name: string, now?: number): boolean;
|
|
395
|
+
start(name: string, config: InstanceConfig, topicMode: boolean, runtimeIdentity?: FleetInstructionsParams["runtimeIdentity"], transition?: TransitionHandle): Promise<void>;
|
|
396
|
+
private startInTransition;
|
|
329
397
|
isPaused(name: string): boolean;
|
|
330
398
|
getLastPausedAt(name: string): number | null;
|
|
331
|
-
pause(name: string): Promise<void>;
|
|
332
|
-
|
|
399
|
+
pause(name: string, reason?: PauseReason): Promise<void>;
|
|
400
|
+
/** When `name` was last woken or (re)started for work, if recorded. */
|
|
401
|
+
wokeAtFor(name: string): number | undefined;
|
|
402
|
+
wake(name: string, timeoutMs?: number, transition?: TransitionHandle, opts?: {
|
|
403
|
+
source?: "coordinator" | "external";
|
|
404
|
+
}): Promise<void>;
|
|
405
|
+
private wakeInner;
|
|
406
|
+
private wakeInTransition;
|
|
333
407
|
/**
|
|
334
408
|
* Ownership boundary for a daemon that is not yet registered: if start()
|
|
335
409
|
* rejects, nothing else will ever dispose it, so do it here before
|
|
@@ -342,7 +416,8 @@ export declare class InstanceLifecycle {
|
|
|
342
416
|
}, name: string, logger: Logger): Promise<void>;
|
|
343
417
|
/** Stop the registered daemon only if it is still `daemon` (a concurrent restart may have replaced it). */
|
|
344
418
|
stopIfCurrent(name: string, daemon: unknown): Promise<boolean>;
|
|
345
|
-
stop(name: string): Promise<void>;
|
|
419
|
+
stop(name: string, transition?: TransitionHandle): Promise<void>;
|
|
420
|
+
private stopInTransition;
|
|
346
421
|
remove(name: string, authorization: ExplicitInstanceRemoval): Promise<void>;
|
|
347
422
|
/** Handle create_instance tool call from a daemon. */
|
|
348
423
|
handleCreate(args: LifecycleCreateArgs, respond: (result: unknown, error?: string) => void, adapterId?: string): Promise<void>;
|
|
@@ -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";
|
|
@@ -83,6 +83,19 @@ export function getUnsafeInstanceDaemonPidReason(pid, dataDir) {
|
|
|
83
83
|
}
|
|
84
84
|
return null;
|
|
85
85
|
}
|
|
86
|
+
/** Thrown by a start whose generation was invalidated (stop/restart) before it could publish. */
|
|
87
|
+
export class SupersededStartError extends Error {
|
|
88
|
+
constructor(name) {
|
|
89
|
+
super(`Start of '${name}' was superseded by a stop or restart before it registered`);
|
|
90
|
+
this.name = "SupersededStartError";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* How long a target just woken for queued work is held from idle auto-pause
|
|
95
|
+
* before its first queued delivery is claimed (Phase 2 design §1.6): the
|
|
96
|
+
* cross-instance idle gate (60s) plus a 60s grace.
|
|
97
|
+
*/
|
|
98
|
+
export const PRECLAIM_LEASE_MS = 120_000;
|
|
86
99
|
/**
|
|
87
100
|
* Working directories of the fleet's other instances on `backendName` (#984):
|
|
88
101
|
* Codex consults them only to decide whether `resume --last` could take a
|
|
@@ -194,6 +207,21 @@ export class InstanceLifecycle {
|
|
|
194
207
|
ctx;
|
|
195
208
|
/** Active daemon processes: instanceName → Daemon */
|
|
196
209
|
daemons = new Map();
|
|
210
|
+
/**
|
|
211
|
+
* Phase 2a: one transition (start / stop / wake / restart) at a time per
|
|
212
|
+
* instance. A call that passes the current transition's handle runs inline;
|
|
213
|
+
* anything else queues.
|
|
214
|
+
*/
|
|
215
|
+
transitionTails = new Map();
|
|
216
|
+
activeTransition = new Map();
|
|
217
|
+
/** Bumped synchronously by stop/restart: a start begun under an older epoch must not publish. */
|
|
218
|
+
lifecycleEpochs = new Map();
|
|
219
|
+
/** Single wake flight per instance: concurrent wakes share it. */
|
|
220
|
+
wakeFlights = new Map();
|
|
221
|
+
/** Starts that begin their idle window now instead of at the persisted last inbound. */
|
|
222
|
+
activitySeedNow = new Set();
|
|
223
|
+
/** When each instance was last woken or started for work — the pre-claim lease clock. */
|
|
224
|
+
wokeAt = new Map();
|
|
197
225
|
/** backend → last auth-error alert time, so one expiry sends one alert. */
|
|
198
226
|
lastAuthAlertAt = new Map();
|
|
199
227
|
/**
|
|
@@ -792,8 +820,9 @@ export class InstanceLifecycle {
|
|
|
792
820
|
// Pattern-matched auth errors get a second opinion from the real CLI
|
|
793
821
|
// before any pause/alert: an agent quoting "401 Unauthorized" in prose
|
|
794
822
|
// must not pause the fleet. A working credential ends the incident here.
|
|
823
|
+
let authVerdict;
|
|
795
824
|
if (data.type === "auth_error") {
|
|
796
|
-
const verdict = await this.verifyAuthError(name);
|
|
825
|
+
const verdict = authVerdict = await this.verifyAuthError(name);
|
|
797
826
|
if (verdict === "valid") {
|
|
798
827
|
this.ctx.logger.info({ name, backend: this.backendOf(name) }, "auth-error pattern ignored — token-free auth check passed (likely conversation text)");
|
|
799
828
|
// Dropping the incident is not enough: the daemon armed its auth
|
|
@@ -887,10 +916,10 @@ export class InstanceLifecycle {
|
|
|
887
916
|
const notificationTarget = this.ptyErrorNotificationTarget(name);
|
|
888
917
|
if (notificationTarget)
|
|
889
918
|
this.notifyIncident(notificationTarget, "pty_error", t("inst.codex_capacity_backoff_exhausted", name, String(attempts)));
|
|
890
|
-
void this.pause(name)
|
|
919
|
+
void this.pause(name, "error")
|
|
891
920
|
.catch(err => this.ctx.logger.warn({ err, name }, "capacity-exhausted pause failed"))
|
|
892
921
|
.finally(() => { if (!this.isPaused(name))
|
|
893
|
-
this.daemons.get(name)?.requestPauseWhenIdle(); });
|
|
922
|
+
this.daemons.get(name)?.requestPauseWhenIdle({ reason: "error" }); });
|
|
894
923
|
}
|
|
895
924
|
else {
|
|
896
925
|
const delays = InstanceLifecycle.CAPACITY_BACKOFF_DELAYS_MS;
|
|
@@ -933,14 +962,96 @@ export class InstanceLifecycle {
|
|
|
933
962
|
// credit and lost work. pause() alone no-ops while the pane is busy (the
|
|
934
963
|
// usual state when auth fails), so mark it to pause as soon as it idles.
|
|
935
964
|
// Queued messages survive: delivery wakes a paused instance.
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
965
|
+
if (data.type === "auth_error" && authVerdict === "unknown") {
|
|
966
|
+
// Nothing vouched for an auth hit the CLI could not be asked about
|
|
967
|
+
// (#1044): pause only if it is still on screen when the turn ends.
|
|
968
|
+
// Never the direct pause here — on an already-idle pane it would
|
|
969
|
+
// /quit before anything looked, and the check would never run. The
|
|
970
|
+
// daemon evaluates at once, so an idle pane is decided now.
|
|
971
|
+
daemon.requestPauseWhenIdle({ reconfirmAuth: true });
|
|
972
|
+
}
|
|
973
|
+
else {
|
|
974
|
+
// The marker must say auth (2b never auto-wakes a login failure); any
|
|
975
|
+
// other pause-action error is an error pause, not an operator one.
|
|
976
|
+
const reason = data.type === "auth_error" ? "auth" : "error";
|
|
977
|
+
void this.pause(name, reason)
|
|
978
|
+
.catch(err => this.ctx.logger.warn({ err, name }, "auth-error pause failed"))
|
|
979
|
+
.finally(() => { if (!this.isPaused(name))
|
|
980
|
+
daemon.requestPauseWhenIdle({ reason }); });
|
|
981
|
+
}
|
|
940
982
|
}
|
|
941
983
|
}, this.ctx.logger, `daemon.pty_error[${name}]`));
|
|
942
984
|
}
|
|
943
|
-
|
|
985
|
+
epochOf(name) {
|
|
986
|
+
return this.lifecycleEpochs.get(name) ?? 0;
|
|
987
|
+
}
|
|
988
|
+
/** Invalidate every start/wake already running for `name` (they will not publish). */
|
|
989
|
+
invalidate(name) {
|
|
990
|
+
this.lifecycleEpochs.set(name, this.epochOf(name) + 1);
|
|
991
|
+
}
|
|
992
|
+
/** Whether `transition` is `name`'s transition and is the one running now. */
|
|
993
|
+
holds(name, transition) {
|
|
994
|
+
return transition !== undefined && transition.name === name && this.activeTransition.get(name) === transition.token;
|
|
995
|
+
}
|
|
996
|
+
/** Run `fn` as `name`'s next transition, or inline when `parent` is the transition running now. */
|
|
997
|
+
async runTransition(name, fn, parent) {
|
|
998
|
+
if (this.holds(name, parent))
|
|
999
|
+
return fn(parent);
|
|
1000
|
+
const previous = this.transitionTails.get(name) ?? Promise.resolve();
|
|
1001
|
+
const handle = { name, token: Symbol(name) };
|
|
1002
|
+
const run = previous.then(() => {
|
|
1003
|
+
this.activeTransition.set(name, handle.token);
|
|
1004
|
+
return fn(handle);
|
|
1005
|
+
}).finally(() => {
|
|
1006
|
+
if (this.activeTransition.get(name) === handle.token)
|
|
1007
|
+
this.activeTransition.delete(name);
|
|
1008
|
+
});
|
|
1009
|
+
const tail = run.then(() => { }, () => { });
|
|
1010
|
+
this.transitionTails.set(name, tail);
|
|
1011
|
+
try {
|
|
1012
|
+
return await run;
|
|
1013
|
+
}
|
|
1014
|
+
finally {
|
|
1015
|
+
if (this.transitionTails.get(name) === tail)
|
|
1016
|
+
this.transitionTails.delete(name);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
/** The next start of `name` is a wake/restart/explicit start: begin its idle window now. */
|
|
1020
|
+
markActivitySeedNow(name) {
|
|
1021
|
+
this.activitySeedNow.add(name);
|
|
1022
|
+
}
|
|
1023
|
+
/** Drop a seed request that no start consumed (the start returned early or failed before it). */
|
|
1024
|
+
clearActivitySeedNow(name) {
|
|
1025
|
+
this.activitySeedNow.delete(name);
|
|
1026
|
+
}
|
|
1027
|
+
/** Record that `name` was just woken or (re)started, opening its pre-claim lease window. */
|
|
1028
|
+
noteWokeForWork(name, now = Date.now()) {
|
|
1029
|
+
this.wokeAt.set(name, now);
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* Phase 2a work lease (design §1.6). Held while a durable submission to the
|
|
1033
|
+
* target is in flight (unbounded: no timer releases a writer), or for
|
|
1034
|
+
* PRECLAIM_LEASE_MS after a wake/start while queued work is still waiting to
|
|
1035
|
+
* be claimed. It holds only the idle-timeout pause and warm-cap eviction —
|
|
1036
|
+
* never an auth-deferred or operator pause.
|
|
1037
|
+
*/
|
|
1038
|
+
hasWorkLease(name, now = Date.now()) {
|
|
1039
|
+
const outbox = this.ctx.deliveryOutbox;
|
|
1040
|
+
if (!outbox)
|
|
1041
|
+
return false;
|
|
1042
|
+
if (outbox.countForTarget(name, ["delivering", "submission_started"]) > 0)
|
|
1043
|
+
return true;
|
|
1044
|
+
const woke = this.wokeAt.get(name);
|
|
1045
|
+
if (woke === undefined || now - woke >= PRECLAIM_LEASE_MS)
|
|
1046
|
+
return false;
|
|
1047
|
+
return outbox.countForTarget(name, ["queued", "retry_wait"]) > 0;
|
|
1048
|
+
}
|
|
1049
|
+
async start(name, config, topicMode, runtimeIdentity, transition) {
|
|
1050
|
+
return this.runTransition(name, () => this.startInTransition(name, config, topicMode, runtimeIdentity), transition);
|
|
1051
|
+
}
|
|
1052
|
+
async startInTransition(name, config, topicMode, runtimeIdentity) {
|
|
1053
|
+
const epoch = this.epochOf(name);
|
|
1054
|
+
const seedNow = this.activitySeedNow.delete(name);
|
|
944
1055
|
if (this.daemons.has(name)) {
|
|
945
1056
|
this.ctx.logger.info({ name }, "Instance already running, skipping");
|
|
946
1057
|
return;
|
|
@@ -979,6 +1090,9 @@ export class InstanceLifecycle {
|
|
|
979
1090
|
model: config.model ?? "default",
|
|
980
1091
|
}, this.ctx.spawnGate, this.ctx.stormWindow, this.ctx.backendOutage);
|
|
981
1092
|
daemon.setDeliveryOutboxPort(this.ctx.deliveryOutbox ?? undefined);
|
|
1093
|
+
daemon.setWorkLeaseCheck(() => this.hasWorkLease(name));
|
|
1094
|
+
if (seedNow)
|
|
1095
|
+
daemon.seedActivityNow();
|
|
982
1096
|
// Read at each spawn so a fleet.yaml reload is honoured (#984).
|
|
983
1097
|
daemon.setPeerWorkingDirectories(() => peerWorkingDirectories(this.ctx.fleetConfig, name, backendName));
|
|
984
1098
|
daemon.setStatusEmojiAvoidList(() => this.ctx.statusEmojiAvoidList?.(name));
|
|
@@ -1026,12 +1140,30 @@ export class InstanceLifecycle {
|
|
|
1026
1140
|
throw new Error(message);
|
|
1027
1141
|
}
|
|
1028
1142
|
}
|
|
1143
|
+
if (this.epochOf(name) !== epoch) {
|
|
1144
|
+
// A stop/restart arrived while this start was reconciling: do not spawn.
|
|
1145
|
+
await daemon.abortStartup().catch(() => { });
|
|
1146
|
+
throw new SupersededStartError(name);
|
|
1147
|
+
}
|
|
1148
|
+
// The startup scan can raise an incident (sign-in screen, fatal modal)
|
|
1149
|
+
// before the handlers below exist and before this daemon is registered;
|
|
1150
|
+
// hold it and deliver it once both are true.
|
|
1151
|
+
daemon.holdStartupIncidents?.();
|
|
1029
1152
|
await InstanceLifecycle.startOrDispose(daemon, name, this.ctx.logger);
|
|
1153
|
+
// Publication fence (Phase 2a): the spawn cannot be cancelled, so a stop or
|
|
1154
|
+
// restart requested meanwhile has been waiting behind this transition. A
|
|
1155
|
+
// superseded start must not register, signal ready, or keep its window.
|
|
1156
|
+
if (this.epochOf(name) !== epoch) {
|
|
1157
|
+
await daemon.abortStartup().catch(err => this.ctx.logger.warn({ err, name }, "Failed to dispose a superseded start"));
|
|
1158
|
+
throw new SupersededStartError(name);
|
|
1159
|
+
}
|
|
1030
1160
|
this.capacityBackoffBaselines.delete(name);
|
|
1031
1161
|
this.daemons.set(name, daemon);
|
|
1162
|
+
if (seedNow)
|
|
1163
|
+
this.noteWokeForWork(name);
|
|
1032
1164
|
this.ctx.onDaemonReady?.(name, daemon.bootId);
|
|
1033
|
-
daemon.on("auto_pause_requested", safeHandler(async () => {
|
|
1034
|
-
await this.pause(name);
|
|
1165
|
+
daemon.on("auto_pause_requested", safeHandler(async (data) => {
|
|
1166
|
+
await this.pause(name, data?.reason ?? "idle");
|
|
1035
1167
|
}, this.ctx.logger, `autoPause[${name}]`));
|
|
1036
1168
|
daemon.on("auto_paused", (data) => {
|
|
1037
1169
|
this.ctx.eventLog?.insert(name, "instance_paused", { reason: "idle", paused_at: data.pausedAt });
|
|
@@ -1057,6 +1189,9 @@ export class InstanceLifecycle {
|
|
|
1057
1189
|
this.attachDeliveryStatusHandlers(name, daemon);
|
|
1058
1190
|
this.ctx.setTopicIcon(name, "green");
|
|
1059
1191
|
this.ctx.touchActivity(name);
|
|
1192
|
+
// Last: handlers attached and daemon registered, so a held startup incident
|
|
1193
|
+
// (pause, notify) finds both.
|
|
1194
|
+
daemon.releaseStartupIncidents?.();
|
|
1060
1195
|
}
|
|
1061
1196
|
isPaused(name) {
|
|
1062
1197
|
return this.daemons.get(name)?.isPaused ?? hasPausedMarker(this.ctx.getInstanceDir(name));
|
|
@@ -1064,7 +1199,7 @@ export class InstanceLifecycle {
|
|
|
1064
1199
|
getLastPausedAt(name) {
|
|
1065
1200
|
return this.daemons.get(name)?.lastPausedAt ?? readPausedAt(this.ctx.getInstanceDir(name));
|
|
1066
1201
|
}
|
|
1067
|
-
async pause(name) {
|
|
1202
|
+
async pause(name, reason = "operator") {
|
|
1068
1203
|
// Final backstop shared by slash, MCP, Settings, warm-cap, and any future
|
|
1069
1204
|
// caller. General is the coordinator and must remain resident to route work.
|
|
1070
1205
|
if (isGeneralInstance(this.ctx.fleetConfig, name)) {
|
|
@@ -1089,7 +1224,7 @@ export class InstanceLifecycle {
|
|
|
1089
1224
|
}
|
|
1090
1225
|
this.ctx.stopStatuslineWatcher(name);
|
|
1091
1226
|
try {
|
|
1092
|
-
await daemon.pause();
|
|
1227
|
+
await daemon.pause(reason);
|
|
1093
1228
|
}
|
|
1094
1229
|
finally {
|
|
1095
1230
|
// A rejected/no-op pause leaves the instance active and must not strand
|
|
@@ -1098,25 +1233,65 @@ export class InstanceLifecycle {
|
|
|
1098
1233
|
this.ctx.startStatuslineWatcher(name);
|
|
1099
1234
|
}
|
|
1100
1235
|
}
|
|
1101
|
-
|
|
1236
|
+
/** When `name` was last woken or (re)started for work, if recorded. */
|
|
1237
|
+
wokeAtFor(name) {
|
|
1238
|
+
return this.wokeAt.get(name);
|
|
1239
|
+
}
|
|
1240
|
+
async wake(name, timeoutMs = 30_000, transition, opts = {}) {
|
|
1241
|
+
const woke = await this.wakeInner(name, timeoutMs, transition);
|
|
1242
|
+
// Only a successful operator/user wake lifts the coordinator's park.
|
|
1243
|
+
if (opts.source !== "coordinator")
|
|
1244
|
+
this.ctx.onExternalWake?.(name);
|
|
1245
|
+
return woke;
|
|
1246
|
+
}
|
|
1247
|
+
async wakeInner(name, timeoutMs, transition) {
|
|
1248
|
+
if (this.holds(name, transition))
|
|
1249
|
+
return this.wakeInTransition(name, timeoutMs, transition);
|
|
1250
|
+
// An explicit wake that meets a restart joins it: the restart of a paused
|
|
1251
|
+
// instance is itself a wake (Phase 2a), and a second flight would race it.
|
|
1252
|
+
const restarting = this.ctx.restartInFlight?.(name);
|
|
1253
|
+
if (restarting) {
|
|
1254
|
+
await restarting;
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
const inFlight = this.wakeFlights.get(name);
|
|
1258
|
+
if (inFlight)
|
|
1259
|
+
return inFlight;
|
|
1260
|
+
const flight = this.runTransition(name, handle => this.wakeInTransition(name, timeoutMs, handle))
|
|
1261
|
+
.finally(() => { if (this.wakeFlights.get(name) === flight)
|
|
1262
|
+
this.wakeFlights.delete(name); });
|
|
1263
|
+
this.wakeFlights.set(name, flight);
|
|
1264
|
+
return flight;
|
|
1265
|
+
}
|
|
1266
|
+
async wakeInTransition(name, timeoutMs, transition) {
|
|
1267
|
+
const epoch = this.epochOf(name);
|
|
1102
1268
|
const daemon = this.daemons.get(name);
|
|
1103
1269
|
if (daemon) {
|
|
1104
1270
|
await daemon.wake(timeoutMs);
|
|
1271
|
+
// A stop/restart requested while the resident CLI was respawning is
|
|
1272
|
+
// queued behind this wake and will tear it down next: do not open a
|
|
1273
|
+
// pre-claim window or restart its watchers on the way out.
|
|
1274
|
+
if (this.epochOf(name) !== epoch)
|
|
1275
|
+
return;
|
|
1105
1276
|
}
|
|
1106
1277
|
else {
|
|
1107
1278
|
const instanceDir = this.ctx.getInstanceDir(name);
|
|
1108
1279
|
if (!hasPausedMarker(instanceDir))
|
|
1109
1280
|
throw new Error(`Cannot wake stopped instance '${name}'`);
|
|
1110
1281
|
const pausedAt = readPausedAt(instanceDir) ?? Date.now();
|
|
1282
|
+
const reason = readPauseReason(instanceDir);
|
|
1111
1283
|
clearPausedMarker(instanceDir);
|
|
1284
|
+
this.markActivitySeedNow(name);
|
|
1112
1285
|
try {
|
|
1113
|
-
await this.ctx.startPersistedPausedInstance(name);
|
|
1286
|
+
await this.ctx.startPersistedPausedInstance(name, transition);
|
|
1114
1287
|
}
|
|
1115
1288
|
catch (err) {
|
|
1116
|
-
|
|
1289
|
+
this.clearActivitySeedNow(name);
|
|
1290
|
+
writePausedMarker(instanceDir, pausedAt, reason);
|
|
1117
1291
|
throw err;
|
|
1118
1292
|
}
|
|
1119
1293
|
}
|
|
1294
|
+
this.noteWokeForWork(name);
|
|
1120
1295
|
this.ctx.startStatuslineWatcher(name);
|
|
1121
1296
|
}
|
|
1122
1297
|
/**
|
|
@@ -1141,7 +1316,16 @@ export class InstanceLifecycle {
|
|
|
1141
1316
|
await this.stop(name);
|
|
1142
1317
|
return true;
|
|
1143
1318
|
}
|
|
1144
|
-
async stop(name) {
|
|
1319
|
+
async stop(name, transition) {
|
|
1320
|
+
// Synchronously, before queueing: a start still spawning must see this and
|
|
1321
|
+
// not publish when it finishes (Phase 2a publication fence).
|
|
1322
|
+
if (!this.holds(name, transition))
|
|
1323
|
+
this.invalidate(name);
|
|
1324
|
+
return this.runTransition(name, () => this.stopInTransition(name), transition);
|
|
1325
|
+
}
|
|
1326
|
+
async stopInTransition(name) {
|
|
1327
|
+
this.invalidate(name);
|
|
1328
|
+
this.wokeAt.delete(name);
|
|
1145
1329
|
this.ctx.setTopicIcon(name, "remove");
|
|
1146
1330
|
// B1: cancel any pending capacity backoff timer so a stopped/removed
|
|
1147
1331
|
// instance is not restarted behind the user's back.
|