@shanesaravia/hive 0.3.0 → 0.5.0
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/CHANGELOG.md +44 -0
- package/README.md +16 -1
- package/node_modules/@hive/shared/dist/status.js +9 -0
- package/node_modules/@hive/shared/dist/types.d.ts +170 -1
- package/node_modules/@hive/shared/dist/types.js +35 -0
- package/package.json +1 -1
- package/packages/server/dist/agents/agentDiscovery.js +64 -0
- package/packages/server/dist/api/rest.js +574 -25
- package/packages/server/dist/api/ws.js +66 -2
- package/packages/server/dist/control/missionQuiesce.js +70 -0
- package/packages/server/dist/health/deriveAlerts.js +8 -0
- package/packages/server/dist/index.js +26 -2
- package/packages/server/dist/loops/loopCommand.js +76 -0
- package/packages/server/dist/loops/loopNoop.js +38 -0
- package/packages/server/dist/loops/loopScheduler.js +58 -0
- package/packages/server/dist/loops/loopStore.js +118 -0
- package/packages/server/dist/loops/monitors.js +38 -0
- package/packages/server/dist/messages/attachmentStore.js +92 -0
- package/packages/server/dist/messages/messagesStore.js +119 -31
- package/packages/server/dist/missions/missionsStore.js +1 -1
- package/packages/server/dist/missions/reopenOnWork.js +7 -0
- package/packages/server/dist/paths/pathResolver.js +112 -17
- package/packages/server/dist/plans/plansStore.js +32 -2
- package/packages/server/dist/reviews/reviewDiff.js +47 -0
- package/packages/server/dist/roster/replyAsk.js +62 -0
- package/packages/server/dist/roster/rosterBuilder.js +41 -5
- package/packages/server/dist/roster/workerIdentity.js +135 -30
- package/packages/server/dist/skills/skillDiscovery.js +28 -4
- package/packages/server/dist/terminals/claudeStreamClient.js +90 -0
- package/packages/server/dist/terminals/codexAppServerClient.js +195 -0
- package/packages/server/dist/terminals/providerDetection.js +27 -0
- package/packages/server/dist/terminals/terminalCapability.js +45 -0
- package/packages/server/dist/terminals/terminalFeatures.js +11 -0
- package/packages/server/dist/terminals/terminalObservability.js +21 -0
- package/packages/server/dist/terminals/terminalRuntime.js +125 -0
- package/packages/server/dist/terminals/terminalStream.js +30 -0
- package/packages/server/dist/transcripts/transcriptReader.js +345 -0
- package/packages/web/dist/assets/index-C1T22qkK.css +2 -0
- package/packages/web/dist/assets/index-CVc04VBT.js +18 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/assets/index-BpEYVjCF.css +0 -2
- package/packages/web/dist/assets/index-rIAIJyuF.js +0 -12
|
@@ -3,7 +3,9 @@ import { allPlanTasks } from "../plans/plansStore.js";
|
|
|
3
3
|
import { deriveChangedFiles, reconcilePlanWorkers } from "../plans/planReconcile.js";
|
|
4
4
|
import { config } from "../config.js";
|
|
5
5
|
import { deriveAlerts } from "../health/deriveAlerts.js";
|
|
6
|
+
import { jobIsLive } from "../control/missionQuiesce.js";
|
|
6
7
|
import { resolveMissionWorkers } from "./workerIdentity.js";
|
|
8
|
+
import { detectReplyAsk } from "./replyAsk.js";
|
|
7
9
|
const ORCHESTRATOR_AGENT_NAME = "hive-orchestrator";
|
|
8
10
|
function dateMs(value) {
|
|
9
11
|
const parsed = value ? Date.parse(value) : NaN;
|
|
@@ -97,7 +99,20 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
|
|
|
97
99
|
choices: latestJob.promptChoices ?? [], impact: "The provider is waiting for this answer before the skill can continue.", createdAt: node.updatedAt, ...missionContext,
|
|
98
100
|
}]
|
|
99
101
|
: [];
|
|
100
|
-
|
|
102
|
+
// A turn that ended by asking the reader something. No structured signal
|
|
103
|
+
// fires for this — the agent simply wrote the question — so without it the
|
|
104
|
+
// mission sits looking finished while it waits for an answer.
|
|
105
|
+
const replyAsk = node.turnCompleted && latestJob && !resolved.has(`ask:${node.jobId}`)
|
|
106
|
+
? detectReplyAsk(latestJob.lastText ?? resultText(latestJob))
|
|
107
|
+
: undefined;
|
|
108
|
+
const fromReply = replyAsk && !fromEvents.length && !parked.length && !providerPrompt.length
|
|
109
|
+
? [{
|
|
110
|
+
id: `ask:${node.jobId}`, missionId: node.missionId, missionName: node.name,
|
|
111
|
+
kind: "question", question: replyAsk.question,
|
|
112
|
+
choices: [], impact: "The mission has finished its turn and is waiting on your answer.", createdAt: node.updatedAt, ...missionContext,
|
|
113
|
+
}]
|
|
114
|
+
: [];
|
|
115
|
+
return [...fromEvents, ...parked, ...providerPrompt, ...fromReply];
|
|
101
116
|
};
|
|
102
117
|
for (const [missionId, group] of groups) {
|
|
103
118
|
group.sort((a, b) => dateMs(a.job.createdAt) - dateMs(b.job.createdAt));
|
|
@@ -138,6 +153,12 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
|
|
|
138
153
|
}));
|
|
139
154
|
const eventJobId = (event) => event.jobId ?? jobIdBySession.get(event.sessionId);
|
|
140
155
|
const missionSummary = missions?.summaryFor(missionId, oldest.job.intent ?? "");
|
|
156
|
+
const turnCompleted = latest.job.state === "done";
|
|
157
|
+
// Every manager on this mission the provider can still act through. A
|
|
158
|
+
// mission is meant to have one; more than one means a resume forked
|
|
159
|
+
// instead of continuing, and the extras are still working the same
|
|
160
|
+
// worktree. Both the roster and the alerts need to know.
|
|
161
|
+
const liveJobIds = group.filter(({ jobId, job }) => jobIsLive(jobId, job, sessions.values())).map(({ jobId }) => jobId);
|
|
141
162
|
// One canonical person per delegated job. The resolver owns every identity
|
|
142
163
|
// decision — native ids, delegation labels, plan tasks, nested agents —
|
|
143
164
|
// and persists them so a later poll cannot re-decide them differently.
|
|
@@ -152,6 +173,7 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
|
|
|
152
173
|
seededNested: workerIdentity?.listNested(missionId),
|
|
153
174
|
now,
|
|
154
175
|
correlationGraceMs: workerCorrelationGraceMs,
|
|
176
|
+
liveJobIds,
|
|
155
177
|
});
|
|
156
178
|
let workers = resolved.workers;
|
|
157
179
|
if (workerIdentity && resolved.dirty) {
|
|
@@ -233,20 +255,34 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
|
|
|
233
255
|
// In that case the runtime is idle, not mission-complete.
|
|
234
256
|
if (activityStatus === "done")
|
|
235
257
|
activityStatus = "idle";
|
|
236
|
-
|
|
237
|
-
|
|
258
|
+
// Provably running right now, as opposed to a plan that merely claims
|
|
259
|
+
// work is open. Workers outlive the manager's own session going quiet
|
|
260
|
+
// between turns, so a mission with live workers is working — reading
|
|
261
|
+
// "Idle" while four of them report progress is simply wrong.
|
|
262
|
+
const liveWork = session?.status === "busy"
|
|
263
|
+
|| latest.job.state === "working" || latest.job.state === "busy"
|
|
264
|
+
|| workers.some((worker) => !worker.doneAt && (worker.jobState === "working" || worker.jobState === "busy"));
|
|
265
|
+
const workExpected = liveWork || Boolean(missionPlan?.phases.some((phase) => phase.status === "working" || phase.status === "reviewing" || allPlanTasks(phase.tasks).some((task) => task.status === "working" || task.status === "reviewing")));
|
|
266
|
+
if ((activityStatus === "idle" || activityStatus === "working") && workExpected && inactiveForMs >= config.stalledAfterMs) {
|
|
267
|
+
// Covers "working" too: a job record left behind by a dead process
|
|
268
|
+
// still claims to be working, and only the elapsed time known here can
|
|
269
|
+
// tell that apart from work actually in progress.
|
|
238
270
|
activityStatus = "stalled";
|
|
239
271
|
}
|
|
272
|
+
else if (activityStatus === "idle" && liveWork) {
|
|
273
|
+
// A stale plan can claim work forever, so only provably live work
|
|
274
|
+
// promotes an idle mission; a plan's claim alone must not.
|
|
275
|
+
activityStatus = "working";
|
|
276
|
+
}
|
|
240
277
|
}
|
|
241
278
|
const name = missions?.nameFor(missionId) ?? oldest.job.name ?? latest.job.name ?? missionId;
|
|
242
|
-
const turnCompleted = latest.job.state === "done";
|
|
243
279
|
const pendingDecisions = pendingDecisionsFor({ missionId, name, jobId: latest.jobId, mission, lifecycleStatus: mission.lifecycleStatus, turnCompleted, recentEvents, updatedAt });
|
|
244
280
|
// An unanswered decision is, by definition, waiting on the user. Do not let the
|
|
245
281
|
// runtime's quiet session decay it to idle/stalled while the question stands.
|
|
246
282
|
if (mission.lifecycleStatus === "active" && pendingDecisions.length && (activityStatus === "idle" || activityStatus === "stalled"))
|
|
247
283
|
activityStatus = "waiting_on_you";
|
|
248
284
|
const runStartedAt = dateMs(latest.job.createdAt) || createdAt;
|
|
249
|
-
const alerts = deriveAlerts({ lifecycle: mission.lifecycleStatus, activity: activityStatus, stale: derived.stale, turnCompleted, workers, events: recentEvents, now, turnTokens: latest.job.tokens ?? 0, runStartedAt });
|
|
285
|
+
const alerts = deriveAlerts({ lifecycle: mission.lifecycleStatus, activity: activityStatus, stale: derived.stale, turnCompleted, workers, events: recentEvents, now, turnTokens: latest.job.tokens ?? 0, runStartedAt, liveJobIds });
|
|
250
286
|
orchestrators.push({
|
|
251
287
|
missionId,
|
|
252
288
|
threadId: missionId,
|
|
@@ -131,13 +131,62 @@ const LAUNCH_NAME_GRACE_MS = 3_000;
|
|
|
131
131
|
*/
|
|
132
132
|
export class WorkerIdentityTable {
|
|
133
133
|
missionId;
|
|
134
|
+
liveJobIds;
|
|
134
135
|
byCanonical = new Map();
|
|
135
136
|
dirty = false;
|
|
136
|
-
constructor(missionId, seeded = []) {
|
|
137
|
+
constructor(missionId, seeded = [], liveJobIds = new Set()) {
|
|
137
138
|
this.missionId = missionId;
|
|
139
|
+
this.liveJobIds = liveJobIds;
|
|
138
140
|
for (const record of seeded)
|
|
139
141
|
this.byCanonical.set(record.canonicalId, { ...record, nativeIds: [...record.nativeIds], labels: [...record.labels], planTaskIds: [...record.planTaskIds], sessionIds: [...record.sessionIds] });
|
|
140
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* Whether this record belongs to a manager other than the one asking, and
|
|
145
|
+
* so must not be found by name.
|
|
146
|
+
*
|
|
147
|
+
* Only a name can be ambiguous. A provider-native agent id is unique by
|
|
148
|
+
* construction and means the same worker wherever it turns up. A plan task
|
|
149
|
+
* id and a delegation label are words an orchestrator chose, and two
|
|
150
|
+
* managers live on one mission choose the same words for different people:
|
|
151
|
+
* on mission f7ae71c7 two of them delegated plan task `t12` to a worker they
|
|
152
|
+
* both called `mr-babysitter`, three minutes apart. Matched across the two,
|
|
153
|
+
* one manager's report retired the other manager's worker.
|
|
154
|
+
*
|
|
155
|
+
* Two conditions keep this from splitting workers that ought to merge. The
|
|
156
|
+
* other job must still be able to act — a job that is over is not competing
|
|
157
|
+
* for anything, so a task picked up on a later turn still finds the worker
|
|
158
|
+
* whose task it is. And the worker must still be running: one that has
|
|
159
|
+
* finished is nobody's to guard, and adopting its task is exactly the retry
|
|
160
|
+
* this table exists to recognise.
|
|
161
|
+
*/
|
|
162
|
+
ownedElsewhere(record, jobId) {
|
|
163
|
+
if (!jobId || !record.lastJobId || record.lastJobId === jobId)
|
|
164
|
+
return false;
|
|
165
|
+
return this.liveJobIds.has(record.lastJobId) && !record.doneAt;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* A canonical id no other record has taken.
|
|
169
|
+
*
|
|
170
|
+
* A native id is unique already. A label is not, and two live managers can
|
|
171
|
+
* hand the same one to two different workers — where the table used to merge
|
|
172
|
+
* them it now keeps them apart, and keying both on the label would silently
|
|
173
|
+
* drop the first from the floor. The label stays the worker's display name
|
|
174
|
+
* either way; only the key it is filed under gains the owning job.
|
|
175
|
+
*/
|
|
176
|
+
freeCanonicalId(label, jobId) {
|
|
177
|
+
if (!this.byCanonical.has(label))
|
|
178
|
+
return label;
|
|
179
|
+
const scoped = `${label}#${(jobId ?? "").slice(0, 8)}`;
|
|
180
|
+
if (jobId && !this.byCanonical.has(scoped))
|
|
181
|
+
return scoped;
|
|
182
|
+
for (let suffix = 2; suffix <= this.byCanonical.size + 2; suffix += 1) {
|
|
183
|
+
const candidate = `${jobId ? scoped : label}#${suffix}`;
|
|
184
|
+
if (!this.byCanonical.has(candidate))
|
|
185
|
+
return candidate;
|
|
186
|
+
}
|
|
187
|
+
// Unreachable: more candidates are tried above than there are records.
|
|
188
|
+
return `${label}#${this.byCanonical.size + 3}`;
|
|
189
|
+
}
|
|
141
190
|
/** Flags a change made by a later pass, so the record set is persisted. */
|
|
142
191
|
markDirty() { this.dirty = true; }
|
|
143
192
|
/**
|
|
@@ -165,15 +214,17 @@ export class WorkerIdentityTable {
|
|
|
165
214
|
if (match)
|
|
166
215
|
return match;
|
|
167
216
|
}
|
|
217
|
+
// Names, unlike native ids, can belong to a live sibling manager.
|
|
218
|
+
const mine = (record) => !this.ownedElsewhere(record, ref.jobId);
|
|
168
219
|
const task = norm(ref.taskId);
|
|
169
220
|
if (task) {
|
|
170
|
-
const match = this.records().find((record) => record.planTaskIds.some((id) => id.toLowerCase() === task));
|
|
221
|
+
const match = this.records().find((record) => mine(record) && record.planTaskIds.some((id) => id.toLowerCase() === task));
|
|
171
222
|
if (match)
|
|
172
223
|
return match;
|
|
173
224
|
}
|
|
174
225
|
const label = norm(ref.label);
|
|
175
226
|
if (label) {
|
|
176
|
-
const match = this.records().find((record) => record.labels.some((item) => item.toLowerCase() === label));
|
|
227
|
+
const match = this.records().find((record) => mine(record) && record.labels.some((item) => item.toLowerCase() === label));
|
|
177
228
|
if (match)
|
|
178
229
|
return match;
|
|
179
230
|
}
|
|
@@ -185,13 +236,16 @@ export class WorkerIdentityTable {
|
|
|
185
236
|
}
|
|
186
237
|
/** Merge this evidence into an existing person, or create one. */
|
|
187
238
|
bind(ref, into, options = {}) {
|
|
188
|
-
const existing = into ?? (options.allowLabelMerge === false ? this.find({ nativeId: ref.nativeId, taskId: ref.taskId }) : this.find(ref));
|
|
239
|
+
const existing = into ?? (options.allowLabelMerge === false ? this.find({ nativeId: ref.nativeId, taskId: ref.taskId, jobId: ref.jobId }) : this.find(ref));
|
|
189
240
|
if (!existing) {
|
|
190
241
|
// A native id is the stable, provider-owned name and is preferred as the
|
|
191
242
|
// canonical id. A label-only worker (Codex, or a delegation whose launch
|
|
192
243
|
// hook never arrived) is keyed by its label, scoped to this mission
|
|
193
|
-
// rather than to a single parent job so a resumed turn reuses it
|
|
194
|
-
|
|
244
|
+
// rather than to a single parent job so a resumed turn reuses it — and
|
|
245
|
+
// disambiguated by job only where a live sibling already holds the name.
|
|
246
|
+
const native = ref.nativeId?.trim();
|
|
247
|
+
const label = ref.label?.trim();
|
|
248
|
+
const canonicalId = native || (label ? this.freeCanonicalId(label, ref.jobId) : undefined);
|
|
195
249
|
if (!canonicalId)
|
|
196
250
|
throw new Error("worker identity requires a native id or a label");
|
|
197
251
|
const record = { canonicalId, missionId: this.missionId, nativeIds: [], labels: [], planTaskIds: [], sessionIds: [], startedAt: ref.at, lastJobId: ref.jobId };
|
|
@@ -273,14 +327,26 @@ export class WorkerIdentityTable {
|
|
|
273
327
|
this.dirty = true;
|
|
274
328
|
return existing;
|
|
275
329
|
}
|
|
276
|
-
/**
|
|
277
|
-
|
|
330
|
+
/**
|
|
331
|
+
* Canonical id for any alias, or undefined when nothing has claimed it.
|
|
332
|
+
*
|
|
333
|
+
* `jobId` is the manager speaking. Where two of them are live and have used
|
|
334
|
+
* the same word for two different workers, the speaker's own worker is the
|
|
335
|
+
* one it means — without that, a manager reporting its worker finished
|
|
336
|
+
* retired whichever record happened to be found first, and the wrong avatar
|
|
337
|
+
* left the floor.
|
|
338
|
+
*/
|
|
339
|
+
canonicalFor(value, jobId) {
|
|
278
340
|
if (!value)
|
|
279
341
|
return undefined;
|
|
280
342
|
const wanted = norm(value);
|
|
281
343
|
if (!wanted)
|
|
282
344
|
return undefined;
|
|
283
|
-
|
|
345
|
+
const matches = this.records().filter((record) => [record.canonicalId, ...record.nativeIds, ...record.labels, ...record.planTaskIds].some((item) => item.toLowerCase() === wanted));
|
|
346
|
+
if (!matches.length)
|
|
347
|
+
return undefined;
|
|
348
|
+
const own = jobId ? matches.find((record) => record.lastJobId === jobId) : undefined;
|
|
349
|
+
return (own ?? matches.find((record) => !this.ownedElsewhere(record, jobId)) ?? matches[0]).canonicalId;
|
|
284
350
|
}
|
|
285
351
|
}
|
|
286
352
|
/**
|
|
@@ -288,9 +354,13 @@ export class WorkerIdentityTable {
|
|
|
288
354
|
* produced a different binding. Version 2 fixed an earlier delegation
|
|
289
355
|
* adopting a later, unrelated launch that was still inside the pairing window,
|
|
290
356
|
* which merged two workers into one actor and left the second delegation's
|
|
291
|
-
* label stranded as a third.
|
|
357
|
+
* label stranded as a third. Version 5 stopped a launch under a still-live
|
|
358
|
+
* sibling job being read as a replacement, which merged the workers of
|
|
359
|
+
* concurrent managers into one person — and clears the records those bindings
|
|
360
|
+
* already wrote. Version 6 stopped a plan task or delegation label finding a
|
|
361
|
+
* live sibling manager's worker at all.
|
|
292
362
|
*/
|
|
293
|
-
export const WORKER_IDENTITY_RESOLVER_VERSION = "
|
|
363
|
+
export const WORKER_IDENTITY_RESOLVER_VERSION = "6";
|
|
294
364
|
/** Persists resolved identities so they survive the bounded event ring. */
|
|
295
365
|
export class WorkerIdentityStore {
|
|
296
366
|
db;
|
|
@@ -358,12 +428,13 @@ export class WorkerIdentityStore {
|
|
|
358
428
|
.run(JSON.stringify(record), Date.now(), missionId, canonicalId);
|
|
359
429
|
}
|
|
360
430
|
/** Canonical worker id for any alias, label, or plan task id. */
|
|
361
|
-
canonicalFor(missionId, value) {
|
|
431
|
+
canonicalFor(missionId, value, jobId) {
|
|
362
432
|
const wanted = value?.trim().toLowerCase();
|
|
363
433
|
if (!wanted)
|
|
364
434
|
return undefined;
|
|
365
|
-
|
|
366
|
-
.some((item) => item.trim().toLowerCase() === wanted))
|
|
435
|
+
const matches = this.list(missionId).filter((record) => [record.canonicalId, ...record.nativeIds, ...record.labels, ...record.planTaskIds]
|
|
436
|
+
.some((item) => item.trim().toLowerCase() === wanted));
|
|
437
|
+
return (jobId ? matches.find((record) => record.lastJobId === jobId) : undefined)?.canonicalId ?? matches[0]?.canonicalId;
|
|
367
438
|
}
|
|
368
439
|
removeMission(missionId) {
|
|
369
440
|
this.db.prepare("DELETE FROM mission_workers WHERE mission_id = ?").run(missionId);
|
|
@@ -400,8 +471,9 @@ export function resolveMissionWorkers(input) {
|
|
|
400
471
|
const events = expandMultiTargetEvents(input.events);
|
|
401
472
|
const correlationGraceMs = input.correlationGraceMs ?? 8_000;
|
|
402
473
|
const pairWindowMs = input.pairWindowMs ?? 30_000;
|
|
474
|
+
const liveJobIds = new Set(input.liveJobIds ?? []);
|
|
403
475
|
const planTasksInput = input.planTasks ?? [];
|
|
404
|
-
const table = new WorkerIdentityTable(missionId, input.seeded);
|
|
476
|
+
const table = new WorkerIdentityTable(missionId, input.seeded, liveJobIds);
|
|
405
477
|
// 1. Hook lifecycle evidence, keyed by the provider's own agent id.
|
|
406
478
|
const nativeEvidence = new Map();
|
|
407
479
|
// An agent launched by a worker is that worker's activity, not a new person
|
|
@@ -468,16 +540,27 @@ export function resolveMissionWorkers(input) {
|
|
|
468
540
|
continue;
|
|
469
541
|
const prior = nativeEvidence.get(nativeId);
|
|
470
542
|
// A stop with no observed launch is usually a nested or background task
|
|
471
|
-
// notification: evidence, but not enough to invent a person
|
|
472
|
-
|
|
543
|
+
// notification: evidence, but not enough to invent a person — unless a
|
|
544
|
+
// persisted record already claims this id. Then the person exists and the
|
|
545
|
+
// launch has merely scrolled out of the event ring, which on a busy
|
|
546
|
+
// mission takes only a couple of minutes: 216 events separated one
|
|
547
|
+
// observed worker's start from its stop, and the ring holds 200. Dropping
|
|
548
|
+
// the stop there left `lastStoppedAt` unset for good, and a worker that
|
|
549
|
+
// had finished a quarter of an hour earlier read "Working" indefinitely.
|
|
550
|
+
const seededRecord = isStop && !prior ? table.find({ nativeId }) : undefined;
|
|
551
|
+
if (isStop && !prior && !seededRecord)
|
|
473
552
|
continue;
|
|
474
553
|
const label = labelOf(event) ?? prior?.label ?? nativeId;
|
|
475
554
|
nativeEvidence.set(nativeId, {
|
|
476
555
|
nativeId,
|
|
477
556
|
jobId,
|
|
478
557
|
label: prior && prior.label !== prior.nativeId ? prior.label : label,
|
|
479
|
-
startedAt: Math.min(prior?.startedAt ?? event.ts, event.ts),
|
|
480
|
-
|
|
558
|
+
startedAt: Math.min(prior?.startedAt ?? seededRecord?.startedAt ?? event.ts, event.ts),
|
|
559
|
+
// A stop must never be read as a start. Where the launch is out of view,
|
|
560
|
+
// the record's own last start is the honest answer; inventing one at the
|
|
561
|
+
// stop's timestamp would make the worker look as though it began at the
|
|
562
|
+
// moment it ended.
|
|
563
|
+
restartedAt: isStart ? event.ts : prior?.restartedAt ?? seededRecord?.lastStartedAt ?? event.ts,
|
|
481
564
|
doneAt: isStop ? event.ts : isStart ? undefined : prior?.doneAt,
|
|
482
565
|
updatedAt: event.ts,
|
|
483
566
|
running: isStart,
|
|
@@ -530,7 +613,7 @@ export function resolveMissionWorkers(input) {
|
|
|
530
613
|
// worker there is nothing a name could change, so it binds immediately as it
|
|
531
614
|
// always has; it is two delegations reaching for the same anonymous launch
|
|
532
615
|
// that need the description to arrive first.
|
|
533
|
-
const contenders = delegations.filter((event) => !table.find({ label: event.targetWorker?.trim() || undefined, taskId: identityTask(event) })?.nativeIds.length).length;
|
|
616
|
+
const contenders = delegations.filter((event) => !table.find({ label: event.targetWorker?.trim() || undefined, taskId: identityTask(event), jobId: eventJobId(event) })?.nativeIds.length).length;
|
|
534
617
|
// Decide nothing while a name may still be arriving. Deferring the whole
|
|
535
618
|
// pass rather than the individual launch matters: pairing sees every launch
|
|
536
619
|
// at once either way, so waiting cannot change which delegation wins a
|
|
@@ -538,7 +621,7 @@ export function resolveMissionWorkers(input) {
|
|
|
538
621
|
const waitingForNames = contenders > 1
|
|
539
622
|
&& launchOrder.some((launch) => !launchIsNamed(launch.nativeId) && now - launch.startedAt < LAUNCH_NAME_GRACE_MS);
|
|
540
623
|
const namedLaunches = waitingForNames ? [] : launchOrder;
|
|
541
|
-
const needsLaunch = (event) => !table.find({ label: event.targetWorker?.trim() || undefined, taskId: identityTask(event) })?.nativeIds.length;
|
|
624
|
+
const needsLaunch = (event) => !table.find({ label: event.targetWorker?.trim() || undefined, taskId: identityTask(event), jobId: eventJobId(event) })?.nativeIds.length;
|
|
542
625
|
const pairing = pairDelegationsByName({
|
|
543
626
|
delegations: delegations.filter(needsLaunch),
|
|
544
627
|
launches: namedLaunches.filter((launch) => !claimed.has(launch.nativeId)),
|
|
@@ -568,7 +651,7 @@ export function resolveMissionWorkers(input) {
|
|
|
568
651
|
return false;
|
|
569
652
|
if (pairing.has(other))
|
|
570
653
|
return false;
|
|
571
|
-
const otherNamed = table.find({ label: other.targetWorker?.trim() || undefined, taskId: identityTask(other) });
|
|
654
|
+
const otherNamed = table.find({ label: other.targetWorker?.trim() || undefined, taskId: identityTask(other), jobId });
|
|
572
655
|
if (otherNamed?.nativeIds.length)
|
|
573
656
|
return false;
|
|
574
657
|
return Math.abs(startedAt - other.ts) < Math.abs(startedAt - event.ts);
|
|
@@ -586,7 +669,7 @@ export function resolveMissionWorkers(input) {
|
|
|
586
669
|
// An already-known label or plan task wins over the launch: a retried or
|
|
587
670
|
// replaced task keeps its person, and the fresh provider id joins it as
|
|
588
671
|
// another alias instead of taking a second desk.
|
|
589
|
-
const named = table.find({ label, taskId: identityTask(event) });
|
|
672
|
+
const named = table.find({ label, taskId: identityTask(event), jobId });
|
|
590
673
|
const known = named ?? (pairable ? table.find({ nativeId: pairable.nativeId }) : undefined);
|
|
591
674
|
// A delegation that already has its worker must not swallow a later,
|
|
592
675
|
// unrelated launch just because it is still inside the pairing window —
|
|
@@ -615,7 +698,16 @@ export function resolveMissionWorkers(input) {
|
|
|
615
698
|
const previousStopped = previousStoppedAt !== Infinity && event.ts >= previousStoppedAt;
|
|
616
699
|
// A retry on a later turn arrives under a new job while the old attempt's
|
|
617
700
|
// job has simply ended; that is a replacement too, stop or no stop.
|
|
618
|
-
|
|
701
|
+
//
|
|
702
|
+
// "Under a new job" only means the old attempt ended if the old job did.
|
|
703
|
+
// Two managers live on one mission at the same time — a resume that forked
|
|
704
|
+
// rather than continued — delegate the same plan task within seconds of
|
|
705
|
+
// each other under two different job ids, and reading the second as a
|
|
706
|
+
// retry of the first merged four separate agents across four sessions into
|
|
707
|
+
// one person: a board refresher's work was credited to the reviewer, and
|
|
708
|
+
// the mission reported a worker had done something that worker never did.
|
|
709
|
+
// A job still able to act has not ended, and its worker is a sibling.
|
|
710
|
+
const laterJob = Boolean(pairable && named?.lastJobId && pairable.jobId !== named.lastJobId && !liveJobIds.has(named.lastJobId));
|
|
619
711
|
const replacing = alreadyLaunched && !sameLaunch && !reported && taskStillOpen && (previousStopped || laterJob);
|
|
620
712
|
const adopt = pairable && (!alreadyLaunched || sameLaunch || replacing) ? pairable : undefined;
|
|
621
713
|
if (adopt)
|
|
@@ -643,7 +735,7 @@ export function resolveMissionWorkers(input) {
|
|
|
643
735
|
// still running. Merging by description exists for a retry — a fresh id for
|
|
644
736
|
// work whose previous attempt has stopped or been reported — and
|
|
645
737
|
// orchestrators reuse descriptions for parallel workers.
|
|
646
|
-
const sameName = label ? table.find({ label }) : undefined;
|
|
738
|
+
const sameName = label ? table.find({ label, jobId: native.jobId }) : undefined;
|
|
647
739
|
const sameNameLive = Boolean(sameName?.nativeIds.length) && sameName.nativeIds.some((id) => { const evidence = nativeEvidence.get(id); return evidence ? !evidence.doneAt : false; })
|
|
648
740
|
&& ![sameName.canonicalId, ...sameName.nativeIds, ...sameName.labels, ...sameName.planTaskIds].some((value) => reportedRefs.has(norm(value) ?? ""));
|
|
649
741
|
const ref = { nativeId: native.nativeId, label, jobId: native.jobId, sessionId: native.sessionId, at: native.startedAt };
|
|
@@ -667,7 +759,7 @@ export function resolveMissionWorkers(input) {
|
|
|
667
759
|
// it, and nobody when the manager did.
|
|
668
760
|
const canonical = event.toolName === "Agent"
|
|
669
761
|
? (event.workerRef?.parentAgentId ? table.canonicalFor(event.workerRef.parentAgentId) : undefined)
|
|
670
|
-
: table.canonicalFor(event.workerRef?.agentId) ?? table.canonicalFor(event.targetWorker);
|
|
762
|
+
: table.canonicalFor(event.workerRef?.agentId) ?? table.canonicalFor(event.targetWorker, eventJobId(event));
|
|
671
763
|
if (canonical)
|
|
672
764
|
backgroundedAt.set(canonical, Math.max(backgroundedAt.get(canonical) ?? 0, event.ts));
|
|
673
765
|
}
|
|
@@ -679,7 +771,7 @@ export function resolveMissionWorkers(input) {
|
|
|
679
771
|
for (const event of events) {
|
|
680
772
|
if (event.source !== "custom")
|
|
681
773
|
continue;
|
|
682
|
-
let canonical = table.canonicalFor(event.targetWorker) ?? table.canonicalFor(event.targetTask);
|
|
774
|
+
let canonical = table.canonicalFor(event.targetWorker, eventJobId(event)) ?? table.canonicalFor(event.targetTask, eventJobId(event));
|
|
683
775
|
if (!canonical)
|
|
684
776
|
continue;
|
|
685
777
|
// A delegation whose launch was never paired sits as an unpublished label
|
|
@@ -774,8 +866,21 @@ export function resolveMissionWorkers(input) {
|
|
|
774
866
|
const closedTasks = taskIds.length && planTasks.length ? planTasks.filter((task) => taskIds.includes(task.id) && (task.status === "completed" || task.status === "cancelled")) : [];
|
|
775
867
|
const taskClosed = closedTasks.length && !openTask ? Math.max(...closedTasks.map((task) => task.updatedAt ?? 0)) || stoppedAt || now : undefined;
|
|
776
868
|
// The stop-settle applies only where nothing else can ever speak for the
|
|
777
|
-
// worker
|
|
778
|
-
|
|
869
|
+
// worker. That is a question about *this* worker, not about the mission:
|
|
870
|
+
// the reason a plan suppresses the settle is that the orchestrator is
|
|
871
|
+
// speaking for its workers through it, and it speaks for a worker only via
|
|
872
|
+
// a task that worker owns. A worker with no task of its own, in a mission
|
|
873
|
+
// that has a plan, had nothing that could ever retire it — no task to
|
|
874
|
+
// close, no settle allowed — so a `SubagentStop` left it reading as merely
|
|
875
|
+
// paused for the rest of the mission. One observed worker sat that way for
|
|
876
|
+
// 27 minutes beside a manager that had been idle for 26 of them.
|
|
877
|
+
// Either direction of the binding counts: a task id the record has claimed,
|
|
878
|
+
// or a task that names this worker itself. Reading only the first missed
|
|
879
|
+
// every worker the plan had been reconciled onto rather than delegated to.
|
|
880
|
+
const ownNames = new Set([record.canonicalId, ...record.nativeIds, ...record.labels].map((name) => name.toLowerCase()));
|
|
881
|
+
const spokenForByPlan = planTasks.some((task) => (taskIds.length > 0 && taskIds.includes(task.id))
|
|
882
|
+
|| (task.workerId ? ownNames.has(task.workerId.toLowerCase()) : false));
|
|
883
|
+
const settledStop = stoppedAt !== undefined && !spokenForByPlan && !awaitingBackgroundWork && now - stoppedAt >= WORKER_STOP_SETTLE_MS ? stoppedAt : undefined;
|
|
779
884
|
// The orchestrator's report closes a worker for good. The provider may
|
|
780
885
|
// still start that agent again — a manager checking on a delayed reply
|
|
781
886
|
// does exactly this, twice in one observed run — but those are follow-ups
|
|
@@ -824,7 +929,7 @@ export function resolveMissionWorkers(input) {
|
|
|
824
929
|
// native id is the one that survives.
|
|
825
930
|
const delegationWaiting = !record.published && !record.delegated && record.nativeIds.length > 0
|
|
826
931
|
&& delegations.some((other) => eventJobId(other) === jobId && needsLaunch(other)
|
|
827
|
-
&& table.find({ label: other.targetWorker?.trim() || undefined, taskId: identityTask(other) })?.published)
|
|
932
|
+
&& table.find({ label: other.targetWorker?.trim() || undefined, taskId: identityTask(other), jobId: eventJobId(other) })?.published)
|
|
828
933
|
&& now - startedAt < correlationGraceMs;
|
|
829
934
|
if (mayYetPair || delegationWaiting || (!corroborated && now - startedAt < correlationGraceMs))
|
|
830
935
|
continue;
|
|
@@ -50,17 +50,41 @@ function collectPluginSkills(root) {
|
|
|
50
50
|
}
|
|
51
51
|
return output;
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* A mission's recorded repository can be a directory that no longer exists —
|
|
55
|
+
* reaching a terminal state reclaims its worktree. The project half of
|
|
56
|
+
* discovery is unavailable then, but the user's own skills are not, so this
|
|
57
|
+
* degrades to them rather than failing outright. Failing threw out ten usable
|
|
58
|
+
* skills over a missing project directory, and, because `sendToMission`
|
|
59
|
+
* discovers skills before it falls back to a live repository, made messaging
|
|
60
|
+
* such a mission impossible.
|
|
61
|
+
*/
|
|
62
|
+
function projectSkills(cwd, provider) {
|
|
63
|
+
let project;
|
|
64
|
+
try {
|
|
65
|
+
project = requireWorkingDirectory(cwd);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
return provider === "codex"
|
|
71
|
+
? collect(path.join(project, ".codex", "skills"), "project")
|
|
72
|
+
: [
|
|
73
|
+
...collect(path.join(project, ".claude", "skills"), "project"),
|
|
74
|
+
...collect(path.join(project, ".claude", "commands"), "project"),
|
|
75
|
+
];
|
|
76
|
+
}
|
|
53
77
|
export function discoverSkills(cwd, provider = "claude") {
|
|
54
|
-
const project = requireWorkingDirectory(cwd);
|
|
55
78
|
const claudeConfig = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
56
79
|
const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
|
80
|
+
// Project entries lead, so the dedup below keeps them over a user skill of
|
|
81
|
+
// the same name.
|
|
57
82
|
const all = provider === "codex" ? [
|
|
58
|
-
...
|
|
83
|
+
...projectSkills(cwd, provider),
|
|
59
84
|
...collect(path.join(codexHome, "skills"), "user"),
|
|
60
85
|
...collectPluginSkills(path.join(codexHome, "plugins", "cache")),
|
|
61
86
|
] : [
|
|
62
|
-
...
|
|
63
|
-
...collect(path.join(project, ".claude", "commands"), "project"),
|
|
87
|
+
...projectSkills(cwd, provider),
|
|
64
88
|
...collect(path.join(claudeConfig, "skills"), "user"),
|
|
65
89
|
...collect(path.join(claudeConfig, "commands"), "user"),
|
|
66
90
|
];
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { Duplex } from "node:stream";
|
|
3
|
+
export function claudeStreamArguments(options) {
|
|
4
|
+
const args = ["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose"];
|
|
5
|
+
if (options.resumeSessionId)
|
|
6
|
+
args.push("--resume", options.resumeSessionId);
|
|
7
|
+
if (options.model)
|
|
8
|
+
args.push("--model", options.model);
|
|
9
|
+
return args;
|
|
10
|
+
}
|
|
11
|
+
/** Reproducible lifecycle spike; production Claude --bg remains follow-up only. */
|
|
12
|
+
export class ClaudeStreamClient {
|
|
13
|
+
stream;
|
|
14
|
+
interruptProcess;
|
|
15
|
+
terminateProcess;
|
|
16
|
+
buffer = "";
|
|
17
|
+
closed = false;
|
|
18
|
+
listeners = new Set();
|
|
19
|
+
constructor(stream, interruptProcess = () => undefined, terminateProcess = () => undefined) {
|
|
20
|
+
this.stream = stream;
|
|
21
|
+
this.interruptProcess = interruptProcess;
|
|
22
|
+
this.terminateProcess = terminateProcess;
|
|
23
|
+
stream.setEncoding("utf8");
|
|
24
|
+
stream.on("data", (chunk) => this.consume(chunk));
|
|
25
|
+
stream.on("close", () => this.finish());
|
|
26
|
+
stream.on("error", (error) => this.emit({ type: "hive/error", message: error.message }));
|
|
27
|
+
}
|
|
28
|
+
static spawn(options, command = "claude") {
|
|
29
|
+
const child = spawn(command, claudeStreamArguments(options), { cwd: options.cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
30
|
+
child.stderr.resume();
|
|
31
|
+
return new ClaudeStreamClient(new ChildDuplex(child), () => { child.kill("SIGINT"); }, () => { child.kill("SIGTERM"); });
|
|
32
|
+
}
|
|
33
|
+
send(text, sessionId) {
|
|
34
|
+
if (this.closed)
|
|
35
|
+
throw new Error("Claude stream client is closed");
|
|
36
|
+
const message = { type: "user", message: { role: "user", content: text } };
|
|
37
|
+
if (sessionId)
|
|
38
|
+
message.session_id = sessionId;
|
|
39
|
+
this.stream.write(`${JSON.stringify(message)}\n`);
|
|
40
|
+
}
|
|
41
|
+
onEvent(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
|
|
42
|
+
interrupt() { if (!this.closed)
|
|
43
|
+
this.interruptProcess(); }
|
|
44
|
+
endInput() { if (!this.closed)
|
|
45
|
+
this.stream.end(); }
|
|
46
|
+
close() { if (!this.closed) {
|
|
47
|
+
this.closed = true;
|
|
48
|
+
this.stream.end();
|
|
49
|
+
this.terminateProcess();
|
|
50
|
+
} }
|
|
51
|
+
consume(chunk) {
|
|
52
|
+
this.buffer += chunk;
|
|
53
|
+
let newline;
|
|
54
|
+
while ((newline = this.buffer.indexOf("\n")) >= 0) {
|
|
55
|
+
const line = this.buffer.slice(0, newline).trim();
|
|
56
|
+
this.buffer = this.buffer.slice(newline + 1);
|
|
57
|
+
if (!line)
|
|
58
|
+
continue;
|
|
59
|
+
try {
|
|
60
|
+
const event = JSON.parse(line);
|
|
61
|
+
if (event && typeof event.type === "string")
|
|
62
|
+
this.emit(event);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
this.emit({ type: "hive/invalid-json", line });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
finish() { if (!this.closed) {
|
|
70
|
+
this.closed = true;
|
|
71
|
+
this.emit({ type: "hive/connection/closed" });
|
|
72
|
+
} }
|
|
73
|
+
emit(event) { for (const listener of this.listeners)
|
|
74
|
+
listener(event); }
|
|
75
|
+
}
|
|
76
|
+
class ChildDuplex extends Duplex {
|
|
77
|
+
child;
|
|
78
|
+
constructor(child) {
|
|
79
|
+
super();
|
|
80
|
+
this.child = child;
|
|
81
|
+
child.stdout.on("data", (chunk) => this.push(chunk));
|
|
82
|
+
child.stdout.on("end", () => this.push(null));
|
|
83
|
+
child.stdout.on("error", (error) => this.destroy(error));
|
|
84
|
+
child.stdin.on("error", (error) => this.destroy(error));
|
|
85
|
+
child.once("exit", () => this.push(null));
|
|
86
|
+
}
|
|
87
|
+
_read() { }
|
|
88
|
+
_write(chunk, encoding, callback) { this.child.stdin.write(chunk, encoding, callback); }
|
|
89
|
+
_final(callback) { this.child.stdin.end(callback); }
|
|
90
|
+
}
|