@hyperdrive.bot/fleet-server 0.3.159 → 0.3.161
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/server/server/agent/agent-manager.d.ts +29 -0
- package/dist/server/server/agent/agent-manager.js +69 -5
- package/dist/server/server/agent/agent-timeline-store.d.ts +10 -0
- package/dist/server/server/agent/agent-timeline-store.js +54 -6
- package/dist/server/server/agent/blocker-log.d.ts +3 -1
- package/dist/server/server/agent/blocker-log.js +17 -5
- package/dist/server/server/agent/card-move-log.d.ts +3 -1
- package/dist/server/server/agent/card-move-log.js +21 -14
- package/dist/server/server/agent/import-sessions.js +1 -1
- package/dist/server/server/agent/jsonl-card-index.d.ts +53 -0
- package/dist/server/server/agent/jsonl-card-index.js +138 -0
- package/dist/server/server/agent/lifecycle-command.d.ts +18 -1
- package/dist/server/server/agent/lifecycle-command.js +21 -2
- package/dist/server/server/agent/mcp-shared.d.ts +1 -0
- package/dist/server/server/agent/provider-snapshot-manager.d.ts +9 -0
- package/dist/server/server/agent/provider-snapshot-manager.js +38 -1
- package/dist/server/server/agent/structured-generation-providers.d.ts +2 -1
- package/dist/server/server/agent/structured-generation-providers.js +21 -8
- package/dist/server/server/fleet/decision-service.d.ts +8 -0
- package/dist/server/server/fleet/decision-service.js +24 -1
- package/dist/server/server/fleet/fleet-controls.js +2 -1
- package/dist/server/server/fleet/fleet-reader.d.ts +12 -0
- package/dist/server/server/fleet/fleet-reader.js +48 -2
- package/dist/server/server/schedule/service.d.ts +4 -0
- package/dist/server/server/schedule/service.js +26 -5
- package/dist/server/server/schedule/store.d.ts +51 -0
- package/dist/server/server/schedule/store.js +184 -16
- package/dist/server/server/session/agent-updates/agent-updates-service.d.ts +16 -1
- package/dist/server/server/session/agent-updates/agent-updates-service.js +59 -2
- package/dist/server/server/session/provider/provider-catalog-session.d.ts +8 -0
- package/dist/server/server/session/provider/provider-catalog-session.js +21 -1
- package/dist/server/server/session.d.ts +16 -0
- package/dist/server/server/session.js +115 -19
- package/dist/server/server/websocket-server.d.ts +5 -0
- package/dist/server/server/websocket-server.js +10 -0
- package/dist/server/server/workspace-directory.d.ts +4 -1
- package/dist/server/server/workspace-directory.js +3 -2
- package/dist/server/web-ui/_expo/static/js/web/{index-1420ca2d21c343afea2c8e2da752cd54.js → index-6a8adbad019c19e5bd71cf06dc31528b.js} +6 -6
- package/dist/server/web-ui/_expo/static/js/web/index-6a8adbad019c19e5bd71cf06dc31528b.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-6a8adbad019c19e5bd71cf06dc31528b.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-1420ca2d21c343afea2c8e2da752cd54.js.map.br → index-6a8adbad019c19e5bd71cf06dc31528b.js.map.br} +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-1420ca2d21c343afea2c8e2da752cd54.js.map.gz → index-6a8adbad019c19e5bd71cf06dc31528b.js.map.gz} +0 -0
- package/dist/server/web-ui/index.html +1 -1
- package/dist/server/web-ui/index.html.br +0 -0
- package/dist/server/web-ui/index.html.gz +0 -0
- package/package.json +6 -6
- package/dist/server/web-ui/_expo/static/js/web/index-1420ca2d21c343afea2c8e2da752cd54.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-1420ca2d21c343afea2c8e2da752cd54.js.gz +0 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
export const DEFAULT_ROWS_PER_CARD = 64;
|
|
3
|
+
export const DEFAULT_MAX_CARDS = 4096;
|
|
4
|
+
export class JsonlCardIndex {
|
|
5
|
+
constructor(options) {
|
|
6
|
+
this.options = options;
|
|
7
|
+
this.cards = new Map();
|
|
8
|
+
this.offset = 0;
|
|
9
|
+
this.mtimeMs = -1;
|
|
10
|
+
this.partial = "";
|
|
11
|
+
this.evicted = false;
|
|
12
|
+
this.rowsPerCard = options.rowsPerCard ?? DEFAULT_ROWS_PER_CARD;
|
|
13
|
+
this.maxCards = options.maxCards ?? DEFAULT_MAX_CARDS;
|
|
14
|
+
}
|
|
15
|
+
/** The card's entry, current with the file. */
|
|
16
|
+
get(cardId) {
|
|
17
|
+
this.sync();
|
|
18
|
+
const entry = this.cards.get(cardId);
|
|
19
|
+
if (entry) {
|
|
20
|
+
// LRU touch.
|
|
21
|
+
this.cards.delete(cardId);
|
|
22
|
+
this.cards.set(cardId, entry);
|
|
23
|
+
return entry;
|
|
24
|
+
}
|
|
25
|
+
return this.evicted ? this.rebuildCard(cardId) : null;
|
|
26
|
+
}
|
|
27
|
+
/** Read whatever was appended since the last call. */
|
|
28
|
+
sync() {
|
|
29
|
+
let info;
|
|
30
|
+
try {
|
|
31
|
+
info = fs.statSync(this.options.filePath);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error?.code !== "ENOENT") {
|
|
35
|
+
this.options.onReadError?.(error);
|
|
36
|
+
}
|
|
37
|
+
if (this.offset > 0)
|
|
38
|
+
this.reset();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (info.size < this.offset) {
|
|
42
|
+
this.reset();
|
|
43
|
+
}
|
|
44
|
+
if (info.size === this.offset && info.mtimeMs === this.mtimeMs) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const length = info.size - this.offset;
|
|
48
|
+
if (length > 0) {
|
|
49
|
+
const buffer = Buffer.alloc(length);
|
|
50
|
+
let fd = null;
|
|
51
|
+
try {
|
|
52
|
+
fd = fs.openSync(this.options.filePath, "r");
|
|
53
|
+
fs.readSync(fd, buffer, 0, length, this.offset);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
this.options.onReadError?.(error);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
if (fd !== null)
|
|
61
|
+
fs.closeSync(fd);
|
|
62
|
+
}
|
|
63
|
+
this.offset = info.size;
|
|
64
|
+
const text = this.partial + buffer.toString("utf8");
|
|
65
|
+
const lines = text.split("\n");
|
|
66
|
+
// A torn trailing line (crash or a writer mid-append) waits for its newline.
|
|
67
|
+
this.partial = lines.pop() ?? "";
|
|
68
|
+
for (const line of lines) {
|
|
69
|
+
const row = this.parseLine(line);
|
|
70
|
+
if (row)
|
|
71
|
+
this.add(row);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
this.mtimeMs = info.mtimeMs;
|
|
75
|
+
}
|
|
76
|
+
parseLine(line) {
|
|
77
|
+
const trimmed = line.trim();
|
|
78
|
+
if (!trimmed)
|
|
79
|
+
return null;
|
|
80
|
+
try {
|
|
81
|
+
return this.options.parse(trimmed);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
add(row, cards = this.cards) {
|
|
88
|
+
const cardId = this.options.cardIdOf(row);
|
|
89
|
+
let entry = cards.get(cardId);
|
|
90
|
+
if (!entry) {
|
|
91
|
+
entry = { rows: [], total: 0, counters: {} };
|
|
92
|
+
cards.set(cardId, entry);
|
|
93
|
+
if (cards === this.cards && cards.size > this.maxCards) {
|
|
94
|
+
const oldest = cards.keys().next().value;
|
|
95
|
+
cards.delete(oldest);
|
|
96
|
+
this.evicted = true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
entry.rows.push(row);
|
|
100
|
+
if (entry.rows.length > this.rowsPerCard)
|
|
101
|
+
entry.rows.shift();
|
|
102
|
+
entry.total += 1;
|
|
103
|
+
for (const [key, value] of Object.entries(this.options.count?.(row) ?? {})) {
|
|
104
|
+
entry.counters[key] = (entry.counters[key] ?? 0) + value;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
rebuildCard(cardId) {
|
|
108
|
+
let raw;
|
|
109
|
+
try {
|
|
110
|
+
raw = fs.readFileSync(this.options.filePath, "utf8");
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const scratch = new Map();
|
|
116
|
+
for (const line of raw.split("\n")) {
|
|
117
|
+
const row = this.parseLine(line);
|
|
118
|
+
if (row && this.options.cardIdOf(row) === cardId)
|
|
119
|
+
this.add(row, scratch);
|
|
120
|
+
}
|
|
121
|
+
const entry = scratch.get(cardId) ?? null;
|
|
122
|
+
if (entry) {
|
|
123
|
+
this.cards.set(cardId, entry);
|
|
124
|
+
if (this.cards.size > this.maxCards) {
|
|
125
|
+
this.cards.delete(this.cards.keys().next().value);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return entry;
|
|
129
|
+
}
|
|
130
|
+
reset() {
|
|
131
|
+
this.cards.clear();
|
|
132
|
+
this.offset = 0;
|
|
133
|
+
this.mtimeMs = -1;
|
|
134
|
+
this.partial = "";
|
|
135
|
+
this.evicted = false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
//# sourceMappingURL=jsonl-card-index.js.map
|
|
@@ -11,6 +11,11 @@ export interface LifecycleAgentManager {
|
|
|
11
11
|
archiveAgent(agentId: string): Promise<{
|
|
12
12
|
archivedAt: string;
|
|
13
13
|
}>;
|
|
14
|
+
markLiveAgentArchived(agentId: string): Promise<{
|
|
15
|
+
archivedAt: string;
|
|
16
|
+
}>;
|
|
17
|
+
completeLiveAgentArchive(agentId: string): Promise<void>;
|
|
18
|
+
scheduleArchiveTeardown(agentId: string): Promise<Error | null>;
|
|
14
19
|
archiveSnapshot(agentId: string, archivedAt: string): Promise<StoredAgentRecord>;
|
|
15
20
|
closeAgent(agentId: string): Promise<void>;
|
|
16
21
|
setLabels(agentId: string, labels: Record<string, string>): Promise<void>;
|
|
@@ -45,8 +50,20 @@ export interface ArchiveAgentResult {
|
|
|
45
50
|
agentId: string;
|
|
46
51
|
archivedAt: string;
|
|
47
52
|
record: StoredAgentRecord;
|
|
53
|
+
/**
|
|
54
|
+
* Archiving the agent's subagents. Already settled unless `deferTeardown` was
|
|
55
|
+
* set. Resolves to the error when it failed, never
|
|
56
|
+
* rejects: the archive itself is already durable.
|
|
57
|
+
*/
|
|
58
|
+
teardown: Promise<Error | null>;
|
|
48
59
|
}
|
|
49
|
-
export declare function archiveAgentCommand(dependencies: AgentLifecycleCommandDependencies, agentId: string
|
|
60
|
+
export declare function archiveAgentCommand(dependencies: AgentLifecycleCommandDependencies, agentId: string, options?: {
|
|
61
|
+
/**
|
|
62
|
+
* Return once the agent is stopped, archived and closed, leaving the cascade
|
|
63
|
+
* to its subagents running in `teardown`.
|
|
64
|
+
*/
|
|
65
|
+
deferTeardown?: boolean;
|
|
66
|
+
}): Promise<ArchiveAgentResult>;
|
|
50
67
|
export declare function closeAgentCommand(dependencies: Pick<AgentLifecycleCommandDependencies, "agentManager">, agentId: string): Promise<void>;
|
|
51
68
|
export interface UpdateAgentResult {
|
|
52
69
|
accepted: boolean;
|
|
@@ -22,10 +22,28 @@ export async function cancelAgentRunCommand(dependencies, agentId) {
|
|
|
22
22
|
cancelled,
|
|
23
23
|
};
|
|
24
24
|
}
|
|
25
|
-
export async function archiveAgentCommand(dependencies, agentId) {
|
|
25
|
+
export async function archiveAgentCommand(dependencies, agentId, options) {
|
|
26
26
|
const liveAgent = dependencies.agentManager.getAgent(agentId);
|
|
27
27
|
let record;
|
|
28
|
-
|
|
28
|
+
let teardown = Promise.resolve(null);
|
|
29
|
+
if (liveAgent && options?.deferTeardown) {
|
|
30
|
+
// The agent itself is stopped, archived and closed BEFORE replying, exactly
|
|
31
|
+
// as without deferral, so the reply and every later request see a closed,
|
|
32
|
+
// archived agent. Only archiving its subagents (a walk over every stored
|
|
33
|
+
// record, then one archive per child) is deferred.
|
|
34
|
+
await cancelAgentRunCommand(dependencies, agentId);
|
|
35
|
+
await dependencies.agentManager.clearAgentAttention(agentId).catch(() => undefined);
|
|
36
|
+
await dependencies.agentManager.markLiveAgentArchived(agentId);
|
|
37
|
+
await dependencies.agentManager.closeAgent(agentId);
|
|
38
|
+
record = await dependencies.agentStorage.get(agentId);
|
|
39
|
+
teardown = dependencies.agentManager.scheduleArchiveTeardown(agentId).then((error) => {
|
|
40
|
+
if (error) {
|
|
41
|
+
dependencies.logger.error({ err: error, agentId }, "Archived agent teardown failed; the archive itself is persisted");
|
|
42
|
+
}
|
|
43
|
+
return error;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
else if (liveAgent) {
|
|
29
47
|
await cancelAgentRunCommand(dependencies, agentId);
|
|
30
48
|
await dependencies.agentManager.clearAgentAttention(agentId).catch(() => undefined);
|
|
31
49
|
await dependencies.agentManager.archiveAgent(agentId);
|
|
@@ -44,6 +62,7 @@ export async function archiveAgentCommand(dependencies, agentId) {
|
|
|
44
62
|
agentId,
|
|
45
63
|
archivedAt: record.archivedAt,
|
|
46
64
|
record,
|
|
65
|
+
teardown,
|
|
47
66
|
};
|
|
48
67
|
}
|
|
49
68
|
export async function closeAgentCommand(dependencies, agentId) {
|
|
@@ -243,5 +243,6 @@ export declare function toScheduleSummary(schedule: z.infer<typeof StoredSchedul
|
|
|
243
243
|
pausedAt: string | null;
|
|
244
244
|
expiresAt: string | null;
|
|
245
245
|
maxRuns: number | null;
|
|
246
|
+
droppedRunCount?: number | undefined;
|
|
246
247
|
};
|
|
247
248
|
//# sourceMappingURL=mcp-shared.d.ts.map
|
|
@@ -70,6 +70,8 @@ export interface AgentManagerProviderState {
|
|
|
70
70
|
}
|
|
71
71
|
export declare class ProviderSnapshotManager {
|
|
72
72
|
private readonly snapshots;
|
|
73
|
+
/** Last snapshot pushed per cwd key, so an unchanged snapshot is not re-broadcast. */
|
|
74
|
+
private readonly lastEmittedFingerprintByCwd;
|
|
73
75
|
private readonly providerLoads;
|
|
74
76
|
private readonly events;
|
|
75
77
|
private destroyed;
|
|
@@ -137,6 +139,13 @@ export declare class ProviderSnapshotManager {
|
|
|
137
139
|
destroy(): void;
|
|
138
140
|
private buildRegistry;
|
|
139
141
|
private resolveParent;
|
|
142
|
+
/**
|
|
143
|
+
* The global snapshot as it stands, WITHOUT starting a load. Every read
|
|
144
|
+
* through `listProviders` / `getSnapshot` warms a cold snapshot, and a global
|
|
145
|
+
* warm-up is pushed to every connected client; background work that can live
|
|
146
|
+
* without an answer uses this instead.
|
|
147
|
+
*/
|
|
148
|
+
peekGlobalProviders(): ProviderSnapshotEntry[];
|
|
140
149
|
private getSnapshotForTarget;
|
|
141
150
|
private getReadyProvider;
|
|
142
151
|
private requireProvider;
|
|
@@ -37,6 +37,8 @@ function resolveDiagnosticTimeoutMs(option, refreshTimeoutMs) {
|
|
|
37
37
|
export class ProviderSnapshotManager {
|
|
38
38
|
constructor(options) {
|
|
39
39
|
this.snapshots = new Map();
|
|
40
|
+
/** Last snapshot pushed per cwd key, so an unchanged snapshot is not re-broadcast. */
|
|
41
|
+
this.lastEmittedFingerprintByCwd = new Map();
|
|
40
42
|
this.providerLoads = new Map();
|
|
41
43
|
this.events = new EventEmitter();
|
|
42
44
|
this.destroyed = false;
|
|
@@ -165,6 +167,17 @@ export class ProviderSnapshotManager {
|
|
|
165
167
|
if (trimmed) {
|
|
166
168
|
return trimmed;
|
|
167
169
|
}
|
|
170
|
+
// The global snapshot first, for providers whose catalog does not depend on
|
|
171
|
+
// the cwd: a new worktree has no snapshot yet, and waiting for one meant
|
|
172
|
+
// create_agent sat behind every provider CLI listing its models. Providers
|
|
173
|
+
// that read project config (opencode, ACP, pi) keep the cwd snapshot, so a
|
|
174
|
+
// project-level default model is still honoured.
|
|
175
|
+
if (CWD_INDEPENDENT_CATALOG_PROVIDERS.has(input.provider)) {
|
|
176
|
+
const globalEntry = this.peekGlobalProviders().find((entry) => entry.provider === input.provider);
|
|
177
|
+
if (globalEntry?.enabled && globalEntry.status === "ready" && globalEntry.models?.length) {
|
|
178
|
+
return (globalEntry.models.find((model) => model.isDefault) ?? globalEntry.models[0]).id;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
168
181
|
const models = await this.listModels({
|
|
169
182
|
provider: input.provider,
|
|
170
183
|
cwd: input.cwd ? expandTilde(input.cwd) : undefined,
|
|
@@ -347,6 +360,16 @@ export class ProviderSnapshotManager {
|
|
|
347
360
|
}),
|
|
348
361
|
};
|
|
349
362
|
}
|
|
363
|
+
/**
|
|
364
|
+
* The global snapshot as it stands, WITHOUT starting a load. Every read
|
|
365
|
+
* through `listProviders` / `getSnapshot` warms a cold snapshot, and a global
|
|
366
|
+
* warm-up is pushed to every connected client; background work that can live
|
|
367
|
+
* without an answer uses this instead.
|
|
368
|
+
*/
|
|
369
|
+
peekGlobalProviders() {
|
|
370
|
+
const snapshot = this.snapshots.get(GLOBAL_PROVIDER_SNAPSHOT_KEY);
|
|
371
|
+
return snapshot ? entriesToArray(snapshot) : [];
|
|
372
|
+
}
|
|
350
373
|
getSnapshotForTarget(target) {
|
|
351
374
|
const providersToWarm = this.resolveProvidersToWarm(target.snapshotCwd);
|
|
352
375
|
if (providersToWarm.length > 0) {
|
|
@@ -635,7 +658,16 @@ export class ProviderSnapshotManager {
|
|
|
635
658
|
if (!snapshot) {
|
|
636
659
|
return;
|
|
637
660
|
}
|
|
638
|
-
|
|
661
|
+
const entries = entriesToArray(snapshot);
|
|
662
|
+
// Every listener serializes and pushes the whole snapshot to its client, so
|
|
663
|
+
// an update that changes nothing (a reload that returns the same catalog, a
|
|
664
|
+
// reset of an already-loading entry) is dropped here.
|
|
665
|
+
const fingerprint = JSON.stringify(entries);
|
|
666
|
+
if (this.lastEmittedFingerprintByCwd.get(cwdKey) === fingerprint) {
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
this.lastEmittedFingerprintByCwd.set(cwdKey, fingerprint);
|
|
670
|
+
this.events.emit("change", entries, cwdKey);
|
|
639
671
|
}
|
|
640
672
|
getOrCreateSnapshot(cwdKey) {
|
|
641
673
|
const existing = this.snapshots.get(cwdKey);
|
|
@@ -726,6 +758,11 @@ function createFetchCatalogOptions(scope, force) {
|
|
|
726
758
|
? { scope: "global", force }
|
|
727
759
|
: { scope: "workspace", cwd: scope.cwd, force };
|
|
728
760
|
}
|
|
761
|
+
/**
|
|
762
|
+
* Providers whose `fetchCatalog` ignores the workspace scope (see opencode,
|
|
763
|
+
* acp and pi, which read project config from the cwd and are NOT listed here).
|
|
764
|
+
*/
|
|
765
|
+
const CWD_INDEPENDENT_CATALOG_PROVIDERS = new Set(["claude", "codex"]);
|
|
729
766
|
export function isGlobalProviderSnapshotKey(cwd) {
|
|
730
767
|
return cwd === GLOBAL_PROVIDER_SNAPSHOT_KEY;
|
|
731
768
|
}
|
|
@@ -16,8 +16,9 @@ export interface StructuredGenerationProviderIdentifier {
|
|
|
16
16
|
}
|
|
17
17
|
export declare const DEFAULT_STRUCTURED_GENERATION_PROVIDERS: readonly StructuredGenerationProviderIdentifier[];
|
|
18
18
|
export interface ResolveStructuredGenerationProvidersOptions {
|
|
19
|
+
/** The agent's cwd; read only when the global snapshot has nothing usable yet. */
|
|
19
20
|
cwd: string;
|
|
20
|
-
providerSnapshotManager: Pick<ProviderSnapshotManager, "listProviders"
|
|
21
|
+
providerSnapshotManager: Pick<ProviderSnapshotManager, "listProviders"> & Partial<Pick<ProviderSnapshotManager, "peekGlobalProviders">>;
|
|
21
22
|
daemonConfig?: StructuredGenerationDaemonConfig | null;
|
|
22
23
|
currentSelection?: {
|
|
23
24
|
provider?: AgentProvider | null;
|
|
@@ -11,19 +11,28 @@ export async function resolveStructuredGenerationProviders(options) {
|
|
|
11
11
|
if (explicitProviders.length === configuredProviders.length) {
|
|
12
12
|
return dedupeProviders(explicitProviders);
|
|
13
13
|
}
|
|
14
|
-
const providerEntries =
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
const providerEntries = peekGlobal(options) ??
|
|
15
|
+
(await options.providerSnapshotManager.listProviders({
|
|
16
|
+
cwd: options.cwd,
|
|
17
|
+
wait: false,
|
|
18
|
+
}));
|
|
18
19
|
const providers = resolveConfiguredProviders(configuredProviders, providerEntries);
|
|
19
20
|
if (providers.length > 0) {
|
|
20
21
|
return dedupeProviders(providers);
|
|
21
22
|
}
|
|
22
23
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
// Background generation (digests, titles, branch names) only needs to know
|
|
25
|
+
// which models exist. Peek at the global snapshot WITHOUT starting a load (a
|
|
26
|
+
// global load is pushed to every client); only when it has nothing usable yet,
|
|
27
|
+
// read the agent's cwd snapshot as before, whose updates go only to clients
|
|
28
|
+
// that asked for that cwd.
|
|
29
|
+
let providerEntries = peekGlobal(options) ?? [];
|
|
30
|
+
if (!providerEntries.some((entry) => entry.enabled && (entry.models?.length ?? 0) > 0)) {
|
|
31
|
+
providerEntries = await options.providerSnapshotManager.listProviders({
|
|
32
|
+
cwd: options.cwd,
|
|
33
|
+
wait: true,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
27
36
|
const enabledEntries = providerEntries.filter((entry) => entry.enabled);
|
|
28
37
|
const modelEntries = enabledEntries.filter((entry) => (entry.models?.length ?? 0) > 0);
|
|
29
38
|
const entriesByProvider = new Map(enabledEntries.map((entry) => [entry.provider, entry]));
|
|
@@ -233,4 +242,8 @@ function readModelMetadataString(model, key) {
|
|
|
233
242
|
const value = model.metadata?.[key];
|
|
234
243
|
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
|
235
244
|
}
|
|
245
|
+
function peekGlobal(options) {
|
|
246
|
+
const entries = options.providerSnapshotManager.peekGlobalProviders?.() ?? [];
|
|
247
|
+
return entries.some((entry) => entry.enabled && entry.status === "ready") ? entries : null;
|
|
248
|
+
}
|
|
236
249
|
//# sourceMappingURL=structured-generation-providers.js.map
|
|
@@ -17,11 +17,19 @@ export interface DecisionServiceOptions {
|
|
|
17
17
|
export declare class LoopDecisionService {
|
|
18
18
|
private readonly dir;
|
|
19
19
|
private readonly now;
|
|
20
|
+
/**
|
|
21
|
+
* Parsed decisions keyed by the directory's mtime. Every create/resolve is an
|
|
22
|
+
* atomic rename into the directory, which bumps its mtime, so one stat decides
|
|
23
|
+
* whether the listing is still current. A list used to readdir and re-parse
|
|
24
|
+
* every file, synchronously, on each RPC.
|
|
25
|
+
*/
|
|
26
|
+
private cached;
|
|
20
27
|
constructor(options: DecisionServiceOptions);
|
|
21
28
|
private pathFor;
|
|
22
29
|
private writeAtomic;
|
|
23
30
|
create(input: CreateLoopDecisionInput): LoopDecision;
|
|
24
31
|
private readAll;
|
|
32
|
+
private readAllFromDisk;
|
|
25
33
|
/**
|
|
26
34
|
* The inbox. Expiry is applied on read so a decision cannot appear pending after its
|
|
27
35
|
* clock ran out just because no tick happened to fire.
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { mkdirSync, readFileSync, readdirSync, writeFileSync, renameSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, renameSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { assertDecisionIsSound, isDecisionPending, LoopDecisionSchema, sortDecisionsByUrgency, } from "@hyperdrive.bot/fleet-protocol/fleet/decisions";
|
|
5
5
|
export class LoopDecisionService {
|
|
6
6
|
constructor(options) {
|
|
7
|
+
/**
|
|
8
|
+
* Parsed decisions keyed by the directory's mtime. Every create/resolve is an
|
|
9
|
+
* atomic rename into the directory, which bumps its mtime, so one stat decides
|
|
10
|
+
* whether the listing is still current. A list used to readdir and re-parse
|
|
11
|
+
* every file, synchronously, on each RPC.
|
|
12
|
+
*/
|
|
13
|
+
this.cached = null;
|
|
7
14
|
this.dir = join(options.paseoHome, "fleet", "decisions");
|
|
8
15
|
this.now = options.now ?? (() => new Date());
|
|
9
16
|
}
|
|
@@ -16,6 +23,7 @@ export class LoopDecisionService {
|
|
|
16
23
|
const temp = `${target}.tmp`;
|
|
17
24
|
writeFileSync(temp, JSON.stringify(decision, null, 2), "utf8");
|
|
18
25
|
renameSync(temp, target);
|
|
26
|
+
this.cached = null;
|
|
19
27
|
}
|
|
20
28
|
create(input) {
|
|
21
29
|
// Unsound decisions are refused here, before a human can ever see one.
|
|
@@ -50,6 +58,21 @@ export class LoopDecisionService {
|
|
|
50
58
|
return decision;
|
|
51
59
|
}
|
|
52
60
|
readAll() {
|
|
61
|
+
let mtimeMs;
|
|
62
|
+
try {
|
|
63
|
+
mtimeMs = statSync(this.dir).mtimeMs;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
if (this.cached && this.cached.mtimeMs === mtimeMs) {
|
|
69
|
+
return [...this.cached.decisions];
|
|
70
|
+
}
|
|
71
|
+
const decisions = this.readAllFromDisk();
|
|
72
|
+
this.cached = { mtimeMs, decisions };
|
|
73
|
+
return [...decisions];
|
|
74
|
+
}
|
|
75
|
+
readAllFromDisk() {
|
|
53
76
|
let names;
|
|
54
77
|
try {
|
|
55
78
|
names = readdirSync(this.dir);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn, execFileSync } from "node:child_process";
|
|
2
2
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { LOOP_PARAMS_VALUES_FILE } from "./fleet-reader.js";
|
|
4
|
+
import { LOOP_PARAMS_VALUES_FILE, rememberCrontab } from "./fleet-reader.js";
|
|
5
5
|
import { validateLoopParams, } from "@hyperdrive.bot/fleet-protocol/fleet/params";
|
|
6
6
|
import { applyModeToCommand, defaultSystemdUnitDir, loopRunnerRegExp, parseModeFromCommand, PAUSE_MARKER, SystemdUserScheduler, } from "./fleet-scheduler.js";
|
|
7
7
|
function resolveRunScript(dir) {
|
|
@@ -55,6 +55,7 @@ function readCrontabText() {
|
|
|
55
55
|
}
|
|
56
56
|
function writeCrontabText(content) {
|
|
57
57
|
execFileSync("crontab", ["-"], { input: content });
|
|
58
|
+
rememberCrontab(content);
|
|
58
59
|
}
|
|
59
60
|
function backupCrontab(paseoHome, content, now) {
|
|
60
61
|
const dir = join(paseoHome, "fleet", "crontab-backups");
|
|
@@ -79,6 +79,18 @@ export declare function discoverLoopDirs(loopsDir: string): Array<{
|
|
|
79
79
|
}>;
|
|
80
80
|
/** Parse `crontab -l` into name → {cadence, mode} by matching run(-poll).sh paths. */
|
|
81
81
|
export declare function parseCrontab(crontab: string): Map<string, CronEntry>;
|
|
82
|
+
/**
|
|
83
|
+
* `crontab -l` is a fork of the whole daemon, and it ran synchronously on every
|
|
84
|
+
* Loops list. Text younger than CRONTAB_CACHE_TTL_MS is served as is; between
|
|
85
|
+
* that and CRONTAB_MAX_STALE_MS it is served once while an async refresh runs;
|
|
86
|
+
* older than that (the first list after a long idle) is read synchronously.
|
|
87
|
+
* The daemon's own crontab writes replace the cache and void any refresh that
|
|
88
|
+
* started before them (`rememberCrontab`).
|
|
89
|
+
*/
|
|
90
|
+
export declare const CRONTAB_CACHE_TTL_MS = 5000;
|
|
91
|
+
export declare const CRONTAB_MAX_STALE_MS = 60000;
|
|
92
|
+
/** Record crontab text the daemon just wrote, or drop the cache with no argument. */
|
|
93
|
+
export declare function rememberCrontab(text?: string): void;
|
|
82
94
|
/** File names a loop uses to declare and store its parameters. */
|
|
83
95
|
export declare const LOOP_PARAMS_SCHEMA_FILE = "params.schema.json";
|
|
84
96
|
export declare const LOOP_PARAMS_VALUES_FILE = "params.json";
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
|
|
17
17
|
import { join } from "node:path";
|
|
18
|
-
import { execFileSync } from "node:child_process";
|
|
18
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
19
19
|
import { homedir } from "node:os";
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
21
|
import { defaultSystemdUnitDir, PAUSE_MARKER, SystemdUserScheduler, } from "./fleet-scheduler.js";
|
|
@@ -141,7 +141,21 @@ export function parseCrontab(crontab) {
|
|
|
141
141
|
}
|
|
142
142
|
return map;
|
|
143
143
|
}
|
|
144
|
-
|
|
144
|
+
/**
|
|
145
|
+
* `crontab -l` is a fork of the whole daemon, and it ran synchronously on every
|
|
146
|
+
* Loops list. Text younger than CRONTAB_CACHE_TTL_MS is served as is; between
|
|
147
|
+
* that and CRONTAB_MAX_STALE_MS it is served once while an async refresh runs;
|
|
148
|
+
* older than that (the first list after a long idle) is read synchronously.
|
|
149
|
+
* The daemon's own crontab writes replace the cache and void any refresh that
|
|
150
|
+
* started before them (`rememberCrontab`).
|
|
151
|
+
*/
|
|
152
|
+
export const CRONTAB_CACHE_TTL_MS = 5000;
|
|
153
|
+
export const CRONTAB_MAX_STALE_MS = 60000;
|
|
154
|
+
let crontabCache = null;
|
|
155
|
+
let crontabRefresh = null;
|
|
156
|
+
/** Bumped by every write; a refresh started under an older generation is dropped. */
|
|
157
|
+
let crontabGeneration = 0;
|
|
158
|
+
function readCrontabNow() {
|
|
145
159
|
try {
|
|
146
160
|
return execFileSync("crontab", ["-l"], { encoding: "utf8" });
|
|
147
161
|
}
|
|
@@ -149,6 +163,38 @@ function readCrontab() {
|
|
|
149
163
|
return "";
|
|
150
164
|
}
|
|
151
165
|
}
|
|
166
|
+
function refreshCrontabInBackground() {
|
|
167
|
+
if (crontabRefresh)
|
|
168
|
+
return;
|
|
169
|
+
const generation = crontabGeneration;
|
|
170
|
+
crontabRefresh = new Promise((resolve) => {
|
|
171
|
+
execFile("crontab", ["-l"], { encoding: "utf8" }, (error, stdout) => {
|
|
172
|
+
if (generation === crontabGeneration) {
|
|
173
|
+
crontabCache = { text: error ? "" : stdout, readAt: Date.now() };
|
|
174
|
+
}
|
|
175
|
+
resolve();
|
|
176
|
+
});
|
|
177
|
+
}).finally(() => {
|
|
178
|
+
crontabRefresh = null;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
function readCrontab() {
|
|
182
|
+
const now = Date.now();
|
|
183
|
+
const age = crontabCache ? now - crontabCache.readAt : Infinity;
|
|
184
|
+
if (!crontabCache || age >= CRONTAB_MAX_STALE_MS) {
|
|
185
|
+
crontabCache = { text: readCrontabNow(), readAt: now };
|
|
186
|
+
return crontabCache.text;
|
|
187
|
+
}
|
|
188
|
+
if (age >= CRONTAB_CACHE_TTL_MS) {
|
|
189
|
+
refreshCrontabInBackground();
|
|
190
|
+
}
|
|
191
|
+
return crontabCache.text;
|
|
192
|
+
}
|
|
193
|
+
/** Record crontab text the daemon just wrote, or drop the cache with no argument. */
|
|
194
|
+
export function rememberCrontab(text) {
|
|
195
|
+
crontabGeneration += 1;
|
|
196
|
+
crontabCache = text === undefined ? null : { text, readAt: Date.now() };
|
|
197
|
+
}
|
|
152
198
|
/** File names a loop uses to declare and store its parameters. */
|
|
153
199
|
export const LOOP_PARAMS_SCHEMA_FILE = "params.schema.json";
|
|
154
200
|
export const LOOP_PARAMS_VALUES_FILE = "params.json";
|
|
@@ -115,6 +115,8 @@ export declare class ScheduleService {
|
|
|
115
115
|
* neither can be fresh while the other is stale.
|
|
116
116
|
*/
|
|
117
117
|
private agentScheduleFacts;
|
|
118
|
+
/** Store version the facts above were built from. */
|
|
119
|
+
private agentScheduleFactsVersion;
|
|
118
120
|
constructor(options: ScheduleServiceOptions);
|
|
119
121
|
/**
|
|
120
122
|
* The daemon's one runs/day limiter, or undefined when the ledger is.
|
|
@@ -140,6 +142,8 @@ export declare class ScheduleService {
|
|
|
140
142
|
* listing, so a caller can never get one refreshed and the other stale.
|
|
141
143
|
*/
|
|
142
144
|
listAgentScheduleFacts(): Promise<AgentScheduleFacts>;
|
|
145
|
+
/** Rebuild the facts index only when the store changed since the last build. */
|
|
146
|
+
private refreshAgentScheduleFacts;
|
|
143
147
|
/**
|
|
144
148
|
* Synchronous read of the cached next-fire index, for the projection paths that
|
|
145
149
|
* are not async and cannot await a store read.
|
|
@@ -101,7 +101,8 @@ function normalizeMaxRuns(value) {
|
|
|
101
101
|
return value;
|
|
102
102
|
}
|
|
103
103
|
function countCompletedRuns(schedule) {
|
|
104
|
-
|
|
104
|
+
// Runs trimmed by the retention cap still count toward maxRuns.
|
|
105
|
+
return (schedule.runs.filter((run) => run.status !== "running").length + (schedule.droppedRunCount ?? 0));
|
|
105
106
|
}
|
|
106
107
|
function shouldCompleteSchedule(schedule, now) {
|
|
107
108
|
if (schedule.expiresAt && new Date(schedule.expiresAt).getTime() <= now.getTime()) {
|
|
@@ -209,6 +210,8 @@ export class ScheduleService {
|
|
|
209
210
|
* neither can be fresh while the other is stale.
|
|
210
211
|
*/
|
|
211
212
|
this.agentScheduleFacts = null;
|
|
213
|
+
/** Store version the facts above were built from. */
|
|
214
|
+
this.agentScheduleFactsVersion = -1;
|
|
212
215
|
this.store = new ScheduleStore(join(options.paseoHome, "schedules"));
|
|
213
216
|
this.logger = options.logger.child({ module: "schedule-service" });
|
|
214
217
|
this.agentManager = options.agentManager;
|
|
@@ -237,6 +240,15 @@ export class ScheduleService {
|
|
|
237
240
|
return this.capLimiter;
|
|
238
241
|
}
|
|
239
242
|
async start() {
|
|
243
|
+
try {
|
|
244
|
+
const removed = await this.store.sweepOrphanedTempFiles();
|
|
245
|
+
if (removed > 0) {
|
|
246
|
+
this.logger.info({ removed }, "Removed orphaned schedule temp files");
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
this.logger.warn({ err: error }, "Failed to sweep orphaned schedule temp files");
|
|
251
|
+
}
|
|
240
252
|
await this.recoverInterruptedRuns();
|
|
241
253
|
// Ingestion ledger maintenance. Order matters: release first, so a claim stranded by a
|
|
242
254
|
// crash records its failure and refreshes last_fail_at, then prune, so a row released
|
|
@@ -361,8 +373,17 @@ export class ScheduleService {
|
|
|
361
373
|
* listing, so a caller can never get one refreshed and the other stale.
|
|
362
374
|
*/
|
|
363
375
|
async listAgentScheduleFacts() {
|
|
364
|
-
const
|
|
376
|
+
const schedules = await this.store.list();
|
|
377
|
+
return this.refreshAgentScheduleFacts(schedules);
|
|
378
|
+
}
|
|
379
|
+
/** Rebuild the facts index only when the store changed since the last build. */
|
|
380
|
+
refreshAgentScheduleFacts(schedules) {
|
|
381
|
+
if (this.agentScheduleFacts && this.agentScheduleFactsVersion === this.store.version) {
|
|
382
|
+
return this.agentScheduleFacts;
|
|
383
|
+
}
|
|
384
|
+
const facts = indexAgentScheduleFacts(schedules);
|
|
365
385
|
this.agentScheduleFacts = facts;
|
|
386
|
+
this.agentScheduleFactsVersion = this.store.version;
|
|
366
387
|
return facts;
|
|
367
388
|
}
|
|
368
389
|
/**
|
|
@@ -510,9 +531,9 @@ export class ScheduleService {
|
|
|
510
531
|
async tick() {
|
|
511
532
|
const now = this.now();
|
|
512
533
|
const schedules = await this.store.list();
|
|
513
|
-
//
|
|
514
|
-
//
|
|
515
|
-
this.
|
|
534
|
+
// Keep the projection cache warm off the same list; a no-op unless the
|
|
535
|
+
// store changed since the last build.
|
|
536
|
+
this.refreshAgentScheduleFacts(schedules);
|
|
516
537
|
for (const schedule of schedules) {
|
|
517
538
|
if (schedule.status !== "active" || !schedule.nextRunAt) {
|
|
518
539
|
continue;
|