harnery 0.3.0 → 0.3.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.
|
@@ -69,8 +69,29 @@ export declare function resolveOwner(): string | null;
|
|
|
69
69
|
*/
|
|
70
70
|
export declare function resolveOwnerWithSource(): {
|
|
71
71
|
owner: string | null;
|
|
72
|
-
source: "env" | "pidmap" | "pidmap_fallback" | "none";
|
|
72
|
+
source: "env" | "pidmap" | "pidmap_fallback" | "session_env" | "active_singleton" | "none";
|
|
73
73
|
};
|
|
74
|
+
/**
|
|
75
|
+
* Resolve the owner by matching the harness session-id env var against the
|
|
76
|
+
* `session_id` of a live heartbeat in this coord root. Returns the matching
|
|
77
|
+
* `instance_id`, or null if there's no session-id env var or no live heartbeat
|
|
78
|
+
* carries it. "Live" reuses the same 10-minute freshness window the singleton
|
|
79
|
+
* fallback applies, so a stale heartbeat from a prior session of the same id
|
|
80
|
+
* doesn't resolve.
|
|
81
|
+
*
|
|
82
|
+
* Exported for unit testing with an injectable root.
|
|
83
|
+
*/
|
|
84
|
+
export declare function resolveOwnerBySessionEnv(root: string): string | null;
|
|
85
|
+
/**
|
|
86
|
+
* Return the instance_id of the sole live agent in this coord root, or null
|
|
87
|
+
* if there are zero or more than one. "Live" reuses the 10-minute heartbeat
|
|
88
|
+
* freshness window the rest of the agents surface applies (kept inline as a
|
|
89
|
+
* literal so this file stays node-builtins-only for vendored downstream use).
|
|
90
|
+
*
|
|
91
|
+
* Exported for unit testing with an injectable root (the caller in
|
|
92
|
+
* `resolveOwnerWithSource` passes `monorepoRoot()`).
|
|
93
|
+
*/
|
|
94
|
+
export declare function resolveSingleActiveOwner(root: string): string | null;
|
|
74
95
|
/**
|
|
75
96
|
* Read and parse a heartbeat file. Returns null if the file is missing,
|
|
76
97
|
* unreadable, or contains malformed JSON. Does not throw.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"coord-client.d.ts","sourceRoot":"","sources":["../../../src/core/agents/coord-client.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAQH,MAAM,WAAW,SAAS;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oGAAoG;IACpG,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,4EAA4E;IAC5E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,0IAA0I;IAC1I,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,sFAAsF;IACtF,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,mEAAmE;IACnE,uBAAuB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxC,wEAAwE;IACxE,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;GASG;AACH,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CA0C5C;AAED,qEAAqE;AACrE,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAIvD;AAED,+EAA+E;AAC/E,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAI1D;AAaD;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CAE5C;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,IAAI;IACxC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,KAAK,GAAG,QAAQ,GAAG,iBAAiB,GAAG,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"coord-client.d.ts","sourceRoot":"","sources":["../../../src/core/agents/coord-client.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAQH,MAAM,WAAW,SAAS;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oGAAoG;IACpG,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,4EAA4E;IAC5E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,0IAA0I;IAC1I,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,sFAAsF;IACtF,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,mEAAmE;IACnE,uBAAuB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxC,wEAAwE;IACxE,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;GASG;AACH,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CA0C5C;AAED,qEAAqE;AACrE,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAIvD;AAED,+EAA+E;AAC/E,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAI1D;AAaD;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CAE5C;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,IAAI;IACxC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,KAAK,GAAG,QAAQ,GAAG,iBAAiB,GAAG,aAAa,GAAG,kBAAkB,GAAG,MAAM,CAAC;CAC5F,CAuDA;AA4BD;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA2BpE;AAED;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAyBpE;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAKlE;AAgBD;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAOtD;AAED;;;GAGG;AACH,wBAAgB,eAAe,IAAI,MAAM,CAIxC"}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* either side without divergence.
|
|
8
8
|
*/
|
|
9
9
|
import { spawnSync } from "node:child_process";
|
|
10
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
10
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
11
11
|
import { resolve } from "node:path";
|
|
12
12
|
/**
|
|
13
13
|
* Resolve the monorepo root for coord-state purposes.
|
|
@@ -133,10 +133,142 @@ export function resolveOwnerWithSource() {
|
|
|
133
133
|
}
|
|
134
134
|
pid = readPpid(pid);
|
|
135
135
|
}
|
|
136
|
-
|
|
137
|
-
owner: fallbackOwner,
|
|
138
|
-
|
|
139
|
-
|
|
136
|
+
if (fallbackOwner) {
|
|
137
|
+
return { owner: fallbackOwner, source: "pidmap_fallback" };
|
|
138
|
+
}
|
|
139
|
+
// The ppid walk found nothing (e.g. a Bash-tool subshell whose process tree
|
|
140
|
+
// doesn't climb back to the harness anchor). Before guessing, try the
|
|
141
|
+
// harness-provided session id from the environment: every supported harness
|
|
142
|
+
// exports its session id into the tool subprocess env, and each heartbeat
|
|
143
|
+
// records the `session_id` it was minted under. Matching the two resolves us
|
|
144
|
+
// unambiguously even with multiple live agents, which is the case the
|
|
145
|
+
// singleton fallback below cannot handle.
|
|
146
|
+
const bySession = resolveOwnerBySessionEnv(root);
|
|
147
|
+
if (bySession) {
|
|
148
|
+
return { owner: bySession, source: "session_env" };
|
|
149
|
+
}
|
|
150
|
+
// Last resort: if exactly one agent is live in this coord root, it's
|
|
151
|
+
// unambiguously us — resolve to it. This is what lets the bare `agents
|
|
152
|
+
// status` / `set-task` the stop hook recommends work without a `--session-id`
|
|
153
|
+
// flag in the common single-agent case. With 0 or 2+ live agents it would be
|
|
154
|
+
// a guess, so we stay null and require the explicit flag.
|
|
155
|
+
const singleton = resolveSingleActiveOwner(root);
|
|
156
|
+
if (singleton) {
|
|
157
|
+
return { owner: singleton, source: "active_singleton" };
|
|
158
|
+
}
|
|
159
|
+
return { owner: null, source: "none" };
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Harness-exported session-id environment variables, in precedence order. Each
|
|
163
|
+
* supported harness propagates its session id into the env of the subprocess it
|
|
164
|
+
* spawns for a tool call (Claude Code's Bash tool, Cursor's terminal, Codex's
|
|
165
|
+
* shell). A coord CLI invoked as such a tool can therefore recover its own
|
|
166
|
+
* identity from the env even when the ppid walk misses.
|
|
167
|
+
*
|
|
168
|
+
* Kept inline (no shared-helper import) so this file stays node-builtins-only
|
|
169
|
+
* for the vendored downstream consumer.
|
|
170
|
+
*/
|
|
171
|
+
const SESSION_ID_ENV_VARS = [
|
|
172
|
+
"HARNERY_AGENT_COORD_SESSION_ID", // explicit override, wins if set
|
|
173
|
+
"CLAUDE_CODE_SESSION_ID",
|
|
174
|
+
"CURSOR_SESSION_ID",
|
|
175
|
+
"CODEX_SESSION_ID",
|
|
176
|
+
];
|
|
177
|
+
/** Read the first non-empty harness session-id env var, or null. */
|
|
178
|
+
function sessionIdFromEnv() {
|
|
179
|
+
for (const key of SESSION_ID_ENV_VARS) {
|
|
180
|
+
const v = process.env[key]?.trim();
|
|
181
|
+
if (v)
|
|
182
|
+
return v;
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Resolve the owner by matching the harness session-id env var against the
|
|
188
|
+
* `session_id` of a live heartbeat in this coord root. Returns the matching
|
|
189
|
+
* `instance_id`, or null if there's no session-id env var or no live heartbeat
|
|
190
|
+
* carries it. "Live" reuses the same 10-minute freshness window the singleton
|
|
191
|
+
* fallback applies, so a stale heartbeat from a prior session of the same id
|
|
192
|
+
* doesn't resolve.
|
|
193
|
+
*
|
|
194
|
+
* Exported for unit testing with an injectable root.
|
|
195
|
+
*/
|
|
196
|
+
export function resolveOwnerBySessionEnv(root) {
|
|
197
|
+
const sessionId = sessionIdFromEnv();
|
|
198
|
+
if (!sessionId)
|
|
199
|
+
return null;
|
|
200
|
+
const activeDir = resolve(root, ".harnery", "active");
|
|
201
|
+
if (!existsSync(activeDir))
|
|
202
|
+
return null;
|
|
203
|
+
const FRESHNESS_SECS = 600;
|
|
204
|
+
const cutoffMs = Date.now() - FRESHNESS_SECS * 1000;
|
|
205
|
+
let files;
|
|
206
|
+
try {
|
|
207
|
+
files = readdirSync(activeDir);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
for (const file of files) {
|
|
213
|
+
if (!file.endsWith(".json"))
|
|
214
|
+
continue;
|
|
215
|
+
try {
|
|
216
|
+
const parsed = JSON.parse(readFileSync(resolve(activeDir, file), "utf8"));
|
|
217
|
+
if (!parsed || parsed.session_id !== sessionId)
|
|
218
|
+
continue;
|
|
219
|
+
if (typeof parsed.instance_id !== "string")
|
|
220
|
+
continue;
|
|
221
|
+
const ts = Date.parse(parsed.last_heartbeat);
|
|
222
|
+
if (Number.isFinite(ts) && ts >= cutoffMs)
|
|
223
|
+
return parsed.instance_id;
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// skip malformed
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Return the instance_id of the sole live agent in this coord root, or null
|
|
233
|
+
* if there are zero or more than one. "Live" reuses the 10-minute heartbeat
|
|
234
|
+
* freshness window the rest of the agents surface applies (kept inline as a
|
|
235
|
+
* literal so this file stays node-builtins-only for vendored downstream use).
|
|
236
|
+
*
|
|
237
|
+
* Exported for unit testing with an injectable root (the caller in
|
|
238
|
+
* `resolveOwnerWithSource` passes `monorepoRoot()`).
|
|
239
|
+
*/
|
|
240
|
+
export function resolveSingleActiveOwner(root) {
|
|
241
|
+
const activeDir = resolve(root, ".harnery", "active");
|
|
242
|
+
if (!existsSync(activeDir))
|
|
243
|
+
return null;
|
|
244
|
+
const FRESHNESS_SECS = 600;
|
|
245
|
+
const cutoffMs = Date.now() - FRESHNESS_SECS * 1000;
|
|
246
|
+
const live = [];
|
|
247
|
+
let files;
|
|
248
|
+
try {
|
|
249
|
+
files = readdirSync(activeDir);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
for (const file of files) {
|
|
255
|
+
if (!file.endsWith(".json"))
|
|
256
|
+
continue;
|
|
257
|
+
try {
|
|
258
|
+
const parsed = JSON.parse(readFileSync(resolve(activeDir, file), "utf8"));
|
|
259
|
+
if (!parsed || typeof parsed.instance_id !== "string")
|
|
260
|
+
continue;
|
|
261
|
+
const ts = Date.parse(parsed.last_heartbeat);
|
|
262
|
+
if (Number.isFinite(ts) && ts >= cutoffMs)
|
|
263
|
+
live.push(parsed.instance_id);
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
// skip malformed
|
|
267
|
+
}
|
|
268
|
+
if (live.length > 1)
|
|
269
|
+
return null; // ambiguous; bail early
|
|
270
|
+
}
|
|
271
|
+
return live.length === 1 ? live[0] : null;
|
|
140
272
|
}
|
|
141
273
|
/**
|
|
142
274
|
* Read and parse a heartbeat file. Returns null if the file is missing,
|
package/package.json
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { spawnSync } from "node:child_process";
|
|
11
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
12
12
|
import { resolve } from "node:path";
|
|
13
13
|
// NOTE: kept dependency-free (node builtins only); this file is vendored verbatim into
|
|
14
14
|
// a downstream consumer, so it cannot import the coordEnv helper.
|
|
@@ -144,7 +144,7 @@ export function resolveOwner(): string | null {
|
|
|
144
144
|
*/
|
|
145
145
|
export function resolveOwnerWithSource(): {
|
|
146
146
|
owner: string | null;
|
|
147
|
-
source: "env" | "pidmap" | "pidmap_fallback" | "none";
|
|
147
|
+
source: "env" | "pidmap" | "pidmap_fallback" | "session_env" | "active_singleton" | "none";
|
|
148
148
|
} {
|
|
149
149
|
const envOwner = process.env.HARNERY_AGENT_COORD_OWNER?.trim();
|
|
150
150
|
if (envOwner) {
|
|
@@ -173,10 +173,134 @@ export function resolveOwnerWithSource(): {
|
|
|
173
173
|
}
|
|
174
174
|
pid = readPpid(pid);
|
|
175
175
|
}
|
|
176
|
-
|
|
177
|
-
owner: fallbackOwner,
|
|
178
|
-
|
|
179
|
-
|
|
176
|
+
if (fallbackOwner) {
|
|
177
|
+
return { owner: fallbackOwner, source: "pidmap_fallback" };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// The ppid walk found nothing (e.g. a Bash-tool subshell whose process tree
|
|
181
|
+
// doesn't climb back to the harness anchor). Before guessing, try the
|
|
182
|
+
// harness-provided session id from the environment: every supported harness
|
|
183
|
+
// exports its session id into the tool subprocess env, and each heartbeat
|
|
184
|
+
// records the `session_id` it was minted under. Matching the two resolves us
|
|
185
|
+
// unambiguously even with multiple live agents, which is the case the
|
|
186
|
+
// singleton fallback below cannot handle.
|
|
187
|
+
const bySession = resolveOwnerBySessionEnv(root);
|
|
188
|
+
if (bySession) {
|
|
189
|
+
return { owner: bySession, source: "session_env" };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Last resort: if exactly one agent is live in this coord root, it's
|
|
193
|
+
// unambiguously us — resolve to it. This is what lets the bare `agents
|
|
194
|
+
// status` / `set-task` the stop hook recommends work without a `--session-id`
|
|
195
|
+
// flag in the common single-agent case. With 0 or 2+ live agents it would be
|
|
196
|
+
// a guess, so we stay null and require the explicit flag.
|
|
197
|
+
const singleton = resolveSingleActiveOwner(root);
|
|
198
|
+
if (singleton) {
|
|
199
|
+
return { owner: singleton, source: "active_singleton" };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { owner: null, source: "none" };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Harness-exported session-id environment variables, in precedence order. Each
|
|
207
|
+
* supported harness propagates its session id into the env of the subprocess it
|
|
208
|
+
* spawns for a tool call (Claude Code's Bash tool, Cursor's terminal, Codex's
|
|
209
|
+
* shell). A coord CLI invoked as such a tool can therefore recover its own
|
|
210
|
+
* identity from the env even when the ppid walk misses.
|
|
211
|
+
*
|
|
212
|
+
* Kept inline (no shared-helper import) so this file stays node-builtins-only
|
|
213
|
+
* for the vendored downstream consumer.
|
|
214
|
+
*/
|
|
215
|
+
const SESSION_ID_ENV_VARS = [
|
|
216
|
+
"HARNERY_AGENT_COORD_SESSION_ID", // explicit override, wins if set
|
|
217
|
+
"CLAUDE_CODE_SESSION_ID",
|
|
218
|
+
"CURSOR_SESSION_ID",
|
|
219
|
+
"CODEX_SESSION_ID",
|
|
220
|
+
] as const;
|
|
221
|
+
|
|
222
|
+
/** Read the first non-empty harness session-id env var, or null. */
|
|
223
|
+
function sessionIdFromEnv(): string | null {
|
|
224
|
+
for (const key of SESSION_ID_ENV_VARS) {
|
|
225
|
+
const v = process.env[key]?.trim();
|
|
226
|
+
if (v) return v;
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Resolve the owner by matching the harness session-id env var against the
|
|
233
|
+
* `session_id` of a live heartbeat in this coord root. Returns the matching
|
|
234
|
+
* `instance_id`, or null if there's no session-id env var or no live heartbeat
|
|
235
|
+
* carries it. "Live" reuses the same 10-minute freshness window the singleton
|
|
236
|
+
* fallback applies, so a stale heartbeat from a prior session of the same id
|
|
237
|
+
* doesn't resolve.
|
|
238
|
+
*
|
|
239
|
+
* Exported for unit testing with an injectable root.
|
|
240
|
+
*/
|
|
241
|
+
export function resolveOwnerBySessionEnv(root: string): string | null {
|
|
242
|
+
const sessionId = sessionIdFromEnv();
|
|
243
|
+
if (!sessionId) return null;
|
|
244
|
+
|
|
245
|
+
const activeDir = resolve(root, ".harnery", "active");
|
|
246
|
+
if (!existsSync(activeDir)) return null;
|
|
247
|
+
const FRESHNESS_SECS = 600;
|
|
248
|
+
const cutoffMs = Date.now() - FRESHNESS_SECS * 1000;
|
|
249
|
+
let files: string[];
|
|
250
|
+
try {
|
|
251
|
+
files = readdirSync(activeDir);
|
|
252
|
+
} catch {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
for (const file of files) {
|
|
256
|
+
if (!file.endsWith(".json")) continue;
|
|
257
|
+
try {
|
|
258
|
+
const parsed = JSON.parse(readFileSync(resolve(activeDir, file), "utf8"));
|
|
259
|
+
if (!parsed || parsed.session_id !== sessionId) continue;
|
|
260
|
+
if (typeof parsed.instance_id !== "string") continue;
|
|
261
|
+
const ts = Date.parse(parsed.last_heartbeat);
|
|
262
|
+
if (Number.isFinite(ts) && ts >= cutoffMs) return parsed.instance_id;
|
|
263
|
+
} catch {
|
|
264
|
+
// skip malformed
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Return the instance_id of the sole live agent in this coord root, or null
|
|
272
|
+
* if there are zero or more than one. "Live" reuses the 10-minute heartbeat
|
|
273
|
+
* freshness window the rest of the agents surface applies (kept inline as a
|
|
274
|
+
* literal so this file stays node-builtins-only for vendored downstream use).
|
|
275
|
+
*
|
|
276
|
+
* Exported for unit testing with an injectable root (the caller in
|
|
277
|
+
* `resolveOwnerWithSource` passes `monorepoRoot()`).
|
|
278
|
+
*/
|
|
279
|
+
export function resolveSingleActiveOwner(root: string): string | null {
|
|
280
|
+
const activeDir = resolve(root, ".harnery", "active");
|
|
281
|
+
if (!existsSync(activeDir)) return null;
|
|
282
|
+
const FRESHNESS_SECS = 600;
|
|
283
|
+
const cutoffMs = Date.now() - FRESHNESS_SECS * 1000;
|
|
284
|
+
const live: string[] = [];
|
|
285
|
+
let files: string[];
|
|
286
|
+
try {
|
|
287
|
+
files = readdirSync(activeDir);
|
|
288
|
+
} catch {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
for (const file of files) {
|
|
292
|
+
if (!file.endsWith(".json")) continue;
|
|
293
|
+
try {
|
|
294
|
+
const parsed = JSON.parse(readFileSync(resolve(activeDir, file), "utf8"));
|
|
295
|
+
if (!parsed || typeof parsed.instance_id !== "string") continue;
|
|
296
|
+
const ts = Date.parse(parsed.last_heartbeat);
|
|
297
|
+
if (Number.isFinite(ts) && ts >= cutoffMs) live.push(parsed.instance_id);
|
|
298
|
+
} catch {
|
|
299
|
+
// skip malformed
|
|
300
|
+
}
|
|
301
|
+
if (live.length > 1) return null; // ambiguous; bail early
|
|
302
|
+
}
|
|
303
|
+
return live.length === 1 ? live[0]! : null;
|
|
180
304
|
}
|
|
181
305
|
|
|
182
306
|
/**
|