@jitsusama/agentic-harness.core 0.6.1 → 0.6.2

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.
@@ -11,6 +11,12 @@
11
11
  * file. When nothing resolves, it fails with a clear message
12
12
  * rather than a cryptic one.
13
13
  *
14
+ * A server nobody has called for `idleMs` is stopped and dropped
15
+ * from the pool, and the next call for its root starts a fresh
16
+ * one: a TypeScript server holds hundreds of megabytes, and a long
17
+ * session touches many roots it never returns to. A call in
18
+ * flight keeps its servers alive however long it takes.
19
+ *
14
20
  * The live server pool lives in this closure's memory for the
15
21
  * life of the process that constructs it. A stateless-per-call
16
22
  * CLI adapter that wants warm servers across invocations needs
@@ -30,6 +36,8 @@ export interface StandaloneBackendOptions {
30
36
  readonly servers?: Readonly<Record<string, ServerConfig>>;
31
37
  /** Environment used for PATH resolution. Defaults to process.env. */
32
38
  readonly env?: NodeJS.ProcessEnv;
39
+ /** How long a server may sit unused before it is stopped. */
40
+ readonly idleMs?: number;
33
41
  }
34
42
  /** A standalone backend with visibility into its live pool. */
35
43
  export interface StandaloneBackend extends LspBackend {
@@ -11,6 +11,12 @@
11
11
  * file. When nothing resolves, it fails with a clear message
12
12
  * rather than a cryptic one.
13
13
  *
14
+ * A server nobody has called for `idleMs` is stopped and dropped
15
+ * from the pool, and the next call for its root starts a fresh
16
+ * one: a TypeScript server holds hundreds of megabytes, and a long
17
+ * session touches many roots it never returns to. A call in
18
+ * flight keeps its servers alive however long it takes.
19
+ *
14
20
  * The live server pool lives in this closure's memory for the
15
21
  * life of the process that constructs it. A stateless-per-call
16
22
  * CLI adapter that wants warm servers across invocations needs
@@ -28,20 +34,56 @@ export class MissingServerError extends Error {
28
34
  this.name = "MissingServerError";
29
35
  }
30
36
  }
37
+ /**
38
+ * Default idle window. Long enough that a burst of work on one
39
+ * project keeps its server warm, short enough that a project left
40
+ * behind gives its memory back within the hour.
41
+ */
42
+ const DEFAULT_IDLE_MS = 10 * 60_000;
31
43
  /** Construct a standalone backend over the given (or default) server map. */
32
44
  export function createStandaloneBackend(options = {}) {
33
45
  const servers = options.servers ?? DEFAULT_SERVERS;
34
46
  const env = options.env ?? process.env;
47
+ const idleMs = options.idleMs ?? DEFAULT_IDLE_MS;
35
48
  const pool = new Map();
49
+ // Calls in flight per pool key, and the stop timer armed when a
50
+ // key's last call finishes.
51
+ const inFlight = new Map();
52
+ const idleTimers = new Map();
36
53
  const poolKey = (name, root) => `${name}|${root}`;
54
+ const claim = (key) => {
55
+ inFlight.set(key, (inFlight.get(key) ?? 0) + 1);
56
+ clearTimeout(idleTimers.get(key));
57
+ idleTimers.delete(key);
58
+ };
59
+ const release = (key) => {
60
+ const left = (inFlight.get(key) ?? 1) - 1;
61
+ if (left > 0) {
62
+ inFlight.set(key, left);
63
+ return;
64
+ }
65
+ inFlight.delete(key);
66
+ const timer = setTimeout(() => stopIdle(key), idleMs);
67
+ // An idle server must never be what keeps the process alive.
68
+ timer.unref();
69
+ idleTimers.set(key, timer);
70
+ };
71
+ const stopIdle = (key) => {
72
+ idleTimers.delete(key);
73
+ if (inFlight.has(key))
74
+ return;
75
+ const started = pool.get(key);
76
+ pool.delete(key);
77
+ void started?.then((server) => server.dispose(), () => { });
78
+ };
37
79
  const instanceFor = (server, root, binary) => {
38
80
  const key = poolKey(server.name, root);
39
81
  const existing = pool.get(key);
40
82
  if (existing)
41
- return existing;
83
+ return { key, started: existing };
42
84
  const started = StandaloneServer.start(server, root, binary);
43
85
  pool.set(key, started);
44
- return started;
86
+ return { key, started };
45
87
  };
46
88
  // Resolve a server's effective command and args for a root. A
47
89
  // server with a `resolve` hook picks its binary per project (the
@@ -65,7 +107,22 @@ export function createStandaloneBackend(options = {}) {
65
107
  binary,
66
108
  };
67
109
  };
68
- const resolveInstances = async (filePath, typeOnly) => {
110
+ /**
111
+ * Run fn against the servers for a file, holding each one's pool
112
+ * key claimed from before it resolves until fn settles, so no
113
+ * server is stopped under a call that is using it.
114
+ */
115
+ const withInstances = async (filePath, typeOnly, fn) => {
116
+ const claimed = [];
117
+ try {
118
+ return await fn(await resolveInstances(filePath, typeOnly, claimed));
119
+ }
120
+ finally {
121
+ for (const key of claimed)
122
+ release(key);
123
+ }
124
+ };
125
+ const resolveInstances = async (filePath, typeOnly, claimed) => {
69
126
  const candidates = serversForFile(filePath, servers).filter((server) => !typeOnly || !server.isLinter);
70
127
  const instances = [];
71
128
  const reasons = [];
@@ -80,7 +137,10 @@ export function createStandaloneBackend(options = {}) {
80
137
  reasons.push(eff.reason);
81
138
  continue;
82
139
  }
83
- instances.push(await instanceFor(eff.config, root, eff.binary));
140
+ const { key, started } = instanceFor(eff.config, root, eff.binary);
141
+ claim(key);
142
+ claimed.push(key);
143
+ instances.push(await started);
84
144
  if (typeOnly)
85
145
  break;
86
146
  }
@@ -95,40 +155,46 @@ export function createStandaloneBackend(options = {}) {
95
155
  return {
96
156
  name: "standalone",
97
157
  async diagnostics(path) {
98
- const instances = await resolveInstances(path, false);
99
- const results = await Promise.all(instances.map((s) => s.diagnose(path)));
100
- return results.flat();
158
+ return withInstances(path, false, async (instances) => {
159
+ const results = await Promise.all(instances.map((s) => s.diagnose(path)));
160
+ return results.flat();
161
+ });
101
162
  },
102
163
  async definition(target) {
103
- const [server] = await resolveInstances(target.path, true);
104
- return server.definition(target);
164
+ return withInstances(target.path, true, ([server]) => server.definition(target));
105
165
  },
106
166
  async references(target) {
107
- const [server] = await resolveInstances(target.path, true);
108
- return server.references(target);
167
+ return withInstances(target.path, true, ([server]) => server.references(target));
109
168
  },
110
169
  async hover(target) {
111
- const [server] = await resolveInstances(target.path, true);
112
- return server.hover(target);
170
+ return withInstances(target.path, true, ([server]) => server.hover(target));
113
171
  },
114
172
  async documentSymbols(path) {
115
- const [server] = await resolveInstances(path, true);
116
- return server.documentSymbols(path);
173
+ return withInstances(path, true, ([server]) => server.documentSymbols(path));
117
174
  },
118
175
  async workspaceSymbols(query) {
119
176
  // Workspace symbols carry no file, so they search every
120
- // server already running; nothing is spawned on demand.
121
- const live = await Promise.all([...pool.values()]);
122
- const results = await Promise.all(live.map((server) => server.workspaceSymbols(query)));
123
- return results.flat();
177
+ // server already running; nothing is spawned on demand, and
178
+ // a server stopped for idleness is not searched until
179
+ // something file-bound starts it again.
180
+ const keys = [...pool.keys()];
181
+ for (const key of keys)
182
+ claim(key);
183
+ try {
184
+ const live = await Promise.all(keys.map((key) => pool.get(key)));
185
+ const results = await Promise.all(live.map((server) => server?.workspaceSymbols(query) ?? []));
186
+ return results.flat();
187
+ }
188
+ finally {
189
+ for (const key of keys)
190
+ release(key);
191
+ }
124
192
  },
125
193
  async rename(target, newName) {
126
- const [server] = await resolveInstances(target.path, true);
127
- return server.rename(target, newName);
194
+ return withInstances(target.path, true, ([server]) => server.rename(target, newName));
128
195
  },
129
196
  async codeActions(path, range) {
130
- const [server] = await resolveInstances(path, true);
131
- return server.codeActions(path, range);
197
+ return withInstances(path, true, ([server]) => server.codeActions(path, range));
132
198
  },
133
199
  syncDocument(path, text) {
134
200
  for (const started of pool.values()) {
@@ -142,6 +208,9 @@ export function createStandaloneBackend(options = {}) {
142
208
  return pool.size;
143
209
  },
144
210
  async dispose() {
211
+ for (const timer of idleTimers.values())
212
+ clearTimeout(timer);
213
+ idleTimers.clear();
145
214
  const started = [...pool.values()];
146
215
  pool.clear();
147
216
  await Promise.all(started.map((s) => s.then((server) => server.dispose())));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jitsusama/agentic-harness.core",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "Pi-agnostic business logic for agentic-harness: state machines, guardian decisions, quest/TDD domain model.",
5
5
  "license": "MIT",
6
6
  "type": "module",