@shanesaravia/hive 0.2.0 → 0.3.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 +53 -0
- package/README.md +16 -0
- package/node_modules/@hive/shared/dist/directStudio.d.ts +6 -0
- package/node_modules/@hive/shared/dist/directStudio.js +12 -0
- package/node_modules/@hive/shared/dist/index.d.ts +2 -0
- package/node_modules/@hive/shared/dist/index.js +2 -0
- package/node_modules/@hive/shared/dist/reviewHall.d.ts +15 -0
- package/node_modules/@hive/shared/dist/reviewHall.js +49 -0
- package/node_modules/@hive/shared/dist/types.d.ts +29 -0
- package/node_modules/@hive/shared/dist/workers.d.ts +14 -0
- package/node_modules/@hive/shared/dist/workers.js +22 -0
- package/package.json +1 -1
- package/packages/server/dist/api/rest.js +130 -11
- package/packages/server/dist/api/ws.js +25 -8
- package/packages/server/dist/control/launcher.js +50 -11
- package/packages/server/dist/control/messaging.js +3 -2
- package/packages/server/dist/health/deriveAlerts.js +1 -2
- package/packages/server/dist/hooks/hookIngest.js +69 -10
- package/packages/server/dist/index.js +20 -4
- package/packages/server/dist/messages/messagesStore.js +25 -12
- package/packages/server/dist/missions/missionsStore.js +19 -0
- package/packages/server/dist/missions/reopenOnWork.js +20 -0
- package/packages/server/dist/plans/planReconcile.js +114 -0
- package/packages/server/dist/plans/plansStore.js +46 -3
- package/packages/server/dist/roster/missionReplay.js +82 -0
- package/packages/server/dist/roster/rosterBuilder.js +93 -189
- package/packages/server/dist/roster/workerIdentity.js +886 -0
- package/packages/server/dist/watch/jobsWatcher.js +55 -24
- package/packages/server/dist/worktrees/worktreeReclaim.js +156 -0
- package/packages/web/dist/assets/index-BpEYVjCF.css +2 -0
- package/packages/web/dist/assets/index-rIAIJyuF.js +12 -0
- package/packages/web/dist/index.html +2 -2
- package/templates/agents/hive-orchestrator.md +1 -0
- package/packages/web/dist/assets/index-BrkIk6ny.js +0 -11
- package/packages/web/dist/assets/index-DJFn_ZsI.css +0 -2
|
@@ -124,8 +124,11 @@ export class PlansStore {
|
|
|
124
124
|
const now = Date.now();
|
|
125
125
|
const mapTasks = (tasks) => tasks.map((task) => {
|
|
126
126
|
const subtasks = task.subtasks?.length ? mapTasks(task.subtasks) : task.subtasks;
|
|
127
|
+
// A worker is addressed by whichever name the orchestrator used; the plan
|
|
128
|
+
// stores the canonical identity so every consumer can compare exactly.
|
|
129
|
+
const workerNames = [input.canonicalWorker, input.targetWorker].filter((value) => Boolean(value));
|
|
127
130
|
const matched = (input.targetTask !== undefined && task.id === input.targetTask)
|
|
128
|
-
|| (
|
|
131
|
+
|| workerNames.some((name) => task.workerId === name || task.owner === name);
|
|
129
132
|
// A parent cannot complete ahead of its subtasks (normalize rejects it).
|
|
130
133
|
const blockedBySubtasks = nextStatus === "completed" && (subtasks ?? []).some((subtask) => subtask.status !== "completed" && subtask.status !== "cancelled");
|
|
131
134
|
if (!matched || blockedBySubtasks || !allowedFrom[nextStatus].includes(task.status)) {
|
|
@@ -138,8 +141,8 @@ export class PlansStore {
|
|
|
138
141
|
status: nextStatus,
|
|
139
142
|
updatedAt: now,
|
|
140
143
|
startedAt: task.startedAt ?? now,
|
|
141
|
-
owner: task.owner ?? input.targetWorker,
|
|
142
|
-
workerId: task.workerId ?? input.targetWorker,
|
|
144
|
+
owner: task.owner ?? input.canonicalWorker ?? input.targetWorker,
|
|
145
|
+
workerId: input.canonicalWorker ?? task.workerId ?? input.targetWorker,
|
|
143
146
|
evidence: nextStatus === "completed" && input.evidence?.trim() ? [...task.evidence, input.evidence.trim().slice(0, 300)] : task.evidence,
|
|
144
147
|
};
|
|
145
148
|
});
|
|
@@ -157,6 +160,46 @@ export class PlansStore {
|
|
|
157
160
|
return undefined;
|
|
158
161
|
return this.replace(missionId, { ...(plan.layout === "flat" ? { tasks: phases[0]?.tasks ?? [] } : { phases }), gates: plan.gates ?? [], approvalStatus: plan.approvalStatus ?? "proposed", approvalReason: plan.approvalReason });
|
|
159
162
|
}
|
|
163
|
+
/**
|
|
164
|
+
* Records that a worker is running a task: stamps the canonical identity and
|
|
165
|
+
* advances a queued/blocked task to working. Forward-only and idempotent, so
|
|
166
|
+
* a repeated snapshot cannot churn revisions, and never marks work complete —
|
|
167
|
+
* a running worker proves work started, not that it succeeded.
|
|
168
|
+
*/
|
|
169
|
+
attachWorker(missionId, input) {
|
|
170
|
+
const plan = this.get(missionId);
|
|
171
|
+
if (!plan)
|
|
172
|
+
return undefined;
|
|
173
|
+
let changed = false;
|
|
174
|
+
const now = Date.now();
|
|
175
|
+
const mapTasks = (tasks) => tasks.map((task) => {
|
|
176
|
+
const subtasks = task.subtasks?.length ? mapTasks(task.subtasks) : task.subtasks;
|
|
177
|
+
if (task.id !== input.taskId)
|
|
178
|
+
return subtasks === task.subtasks ? task : { ...task, subtasks };
|
|
179
|
+
const advancing = task.status === "queued" || task.status === "blocked";
|
|
180
|
+
if (task.workerId === input.canonicalWorker && !advancing)
|
|
181
|
+
return subtasks === task.subtasks ? task : { ...task, subtasks };
|
|
182
|
+
changed = true;
|
|
183
|
+
return {
|
|
184
|
+
...task,
|
|
185
|
+
subtasks,
|
|
186
|
+
status: advancing ? "working" : task.status,
|
|
187
|
+
owner: task.owner ?? input.label ?? input.canonicalWorker,
|
|
188
|
+
workerId: input.canonicalWorker,
|
|
189
|
+
startedAt: task.startedAt ?? now,
|
|
190
|
+
updatedAt: now,
|
|
191
|
+
};
|
|
192
|
+
});
|
|
193
|
+
const phases = plan.phases.map((phase) => {
|
|
194
|
+
const tasks = mapTasks(phase.tasks);
|
|
195
|
+
const statuses = allPlanTasks(tasks).map((task) => task.status);
|
|
196
|
+
const status = (phase.status === "queued" || phase.status === "completed") && statuses.some((s) => s === "working" || s === "reviewing") ? "working" : phase.status;
|
|
197
|
+
return { ...phase, status, tasks };
|
|
198
|
+
});
|
|
199
|
+
if (!changed)
|
|
200
|
+
return undefined;
|
|
201
|
+
return this.replace(missionId, { ...(plan.layout === "flat" ? { tasks: phases[0]?.tasks ?? [] } : { phases }), gates: plan.gates ?? [], approvalStatus: plan.approvalStatus ?? "proposed", approvalReason: plan.approvalReason });
|
|
202
|
+
}
|
|
160
203
|
removeMission(missionId) { this.db.transaction(() => { this.db.prepare("DELETE FROM mission_plans WHERE mission_id = ?").run(missionId); this.db.prepare("DELETE FROM mission_plan_revisions WHERE mission_id = ?").run(missionId); })(); for (const listener of this.listeners)
|
|
161
204
|
listener(); }
|
|
162
205
|
migrateMission(oldId, newId) { this.db.transaction(() => { const current = this.get(oldId); if (current) {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { MissionsStore } from "../missions/missionsStore.js";
|
|
6
|
+
import { buildFleetSnapshot } from "./rosterBuilder.js";
|
|
7
|
+
import { WorkerIdentityStore } from "./workerIdentity.js";
|
|
8
|
+
const fixturesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
|
|
9
|
+
export function loadMissionFixture(name) {
|
|
10
|
+
return JSON.parse(fs.readFileSync(path.join(fixturesDir, `${name}.events.json`), "utf8"));
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The manager's job is `done` between a Stop and the next prompt — the turn
|
|
14
|
+
* boundary that repeatedly got mistaken for the end of the mission.
|
|
15
|
+
*/
|
|
16
|
+
function jobStateAt(events) {
|
|
17
|
+
let state = "working";
|
|
18
|
+
for (const event of events) {
|
|
19
|
+
if (event.hookEventName === "Stop")
|
|
20
|
+
state = "done";
|
|
21
|
+
if (event.hookEventName === "UserPromptSubmit")
|
|
22
|
+
state = "working";
|
|
23
|
+
}
|
|
24
|
+
return state;
|
|
25
|
+
}
|
|
26
|
+
export function replayMission(fixture, options = {}) {
|
|
27
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `hive-replay-${fixture.name}-`));
|
|
28
|
+
options.cleanup?.(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
29
|
+
const missions = new MissionsStore(path.join(dir, "missions.json"), path.join(dir, "threads.json"));
|
|
30
|
+
missions.init();
|
|
31
|
+
const primaryJob = fixture.jobs[0];
|
|
32
|
+
const mission = missions.create({
|
|
33
|
+
name: fixture.name, objective: fixture.objective, jobId: primaryJob?.jobId ?? fixture.missionId,
|
|
34
|
+
sessionId: fixture.sessionId, mode: fixture.mode,
|
|
35
|
+
});
|
|
36
|
+
for (const { jobId } of fixture.jobs.slice(1))
|
|
37
|
+
missions.linkJob(mission.id, jobId);
|
|
38
|
+
const identity = new WorkerIdentityStore(path.join(dir, "hive.db"));
|
|
39
|
+
// A read-only stand-in for the plans store: the recorded revisions already
|
|
40
|
+
// contain the worker bindings production wrote, so replaying must not write
|
|
41
|
+
// new ones over them.
|
|
42
|
+
const revisions = [...(fixture.planRevisions ?? [])].sort((a, b) => a.updatedAt - b.updatedAt);
|
|
43
|
+
const plansAt = (at) => ({
|
|
44
|
+
get: () => [...revisions].reverse().find((entry) => entry.updatedAt <= at)?.plan,
|
|
45
|
+
attachWorker: () => undefined,
|
|
46
|
+
});
|
|
47
|
+
const start = fixture.events[0]?.ts ?? 0;
|
|
48
|
+
const steps = [];
|
|
49
|
+
for (let index = 1; index <= fixture.events.length; index += 1) {
|
|
50
|
+
const seen = fixture.events.slice(0, index);
|
|
51
|
+
const event = seen[seen.length - 1];
|
|
52
|
+
const now = event.ts + 1;
|
|
53
|
+
// The REST ingest moves the mission to review on this event; a replay that
|
|
54
|
+
// left it active never showed the office the moment the manager leaves
|
|
55
|
+
// for the hall while workers are still on their way out (mission test41).
|
|
56
|
+
if (event.source === "custom" && event.phase === "ready_for_review")
|
|
57
|
+
missions.setLifecycleStatus(mission.id, "ready_for_review");
|
|
58
|
+
const state = jobStateAt(seen);
|
|
59
|
+
const jobs = new Map(fixture.jobs.map(({ jobId, job }) => [jobId, {
|
|
60
|
+
...job,
|
|
61
|
+
state,
|
|
62
|
+
// The provider's fan file is written as workers finish; before the turn
|
|
63
|
+
// ends the roster only has hook evidence to go on. A recorded fixture
|
|
64
|
+
// holds the file's *final* contents, so entries — and their doneAt — are
|
|
65
|
+
// masked until the moment they were really written. Without that the
|
|
66
|
+
// replay hands the roster a worker's completion long before it happened.
|
|
67
|
+
fan: state === "done"
|
|
68
|
+
? job.fan.filter((entry) => entry.startedAt <= now).map((entry) => ({ ...entry, doneAt: entry.doneAt !== undefined && entry.doneAt <= now ? entry.doneAt : undefined }))
|
|
69
|
+
: [],
|
|
70
|
+
}]));
|
|
71
|
+
const events = {
|
|
72
|
+
recentFor: (id, limit = 50) => seen.filter((item) => item.sessionId === id || item.jobId === id).slice(-limit),
|
|
73
|
+
sessionIds: () => [...new Set(seen.map((item) => item.sessionId))],
|
|
74
|
+
};
|
|
75
|
+
steps.push({ elapsedMs: event.ts - start, event, snapshot: buildFleetSnapshot(new Map(), jobs, events, missions, undefined, plansAt(now), now, identity) });
|
|
76
|
+
}
|
|
77
|
+
return steps;
|
|
78
|
+
}
|
|
79
|
+
/** The mission node under replay, for the one mission a fixture contains. */
|
|
80
|
+
export function replayedMission(step) {
|
|
81
|
+
return step.snapshot.orchestrators[0];
|
|
82
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { deriveStatus, } from "@hive/shared";
|
|
2
2
|
import { allPlanTasks } from "../plans/plansStore.js";
|
|
3
|
+
import { deriveChangedFiles, reconcilePlanWorkers } from "../plans/planReconcile.js";
|
|
3
4
|
import { config } from "../config.js";
|
|
4
5
|
import { deriveAlerts } from "../health/deriveAlerts.js";
|
|
6
|
+
import { resolveMissionWorkers } from "./workerIdentity.js";
|
|
5
7
|
const ORCHESTRATOR_AGENT_NAME = "hive-orchestrator";
|
|
6
8
|
function dateMs(value) {
|
|
7
9
|
const parsed = value ? Date.parse(value) : NaN;
|
|
@@ -18,7 +20,7 @@ function resultText(job) {
|
|
|
18
20
|
return undefined;
|
|
19
21
|
}
|
|
20
22
|
/** Joins Claude's short-lived jobs into durable, Hive-managed missions. */
|
|
21
|
-
export function buildFleetSnapshot(sessions, jobs, events, missions, messageStore, planStore, now = Date.now()) {
|
|
23
|
+
export function buildFleetSnapshot(sessions, jobs, events, missions, messageStore, planStore, now = Date.now(), workerIdentity) {
|
|
22
24
|
const workerCorrelationGraceMs = 8_000;
|
|
23
25
|
const orchestrators = [];
|
|
24
26
|
const other = [];
|
|
@@ -52,6 +54,51 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
|
|
|
52
54
|
group.push({ jobId, job });
|
|
53
55
|
groups.set(missionId, group);
|
|
54
56
|
}
|
|
57
|
+
// Per-mission pending decisions. Used both for the global inbox and, inside the
|
|
58
|
+
// mission loop, to keep a mission reading as waiting_on_you for as long as a
|
|
59
|
+
// decision is unanswered — even after the provider session record has gone
|
|
60
|
+
// quiet — so the office lighting, desk, status pill, and "?" badge agree.
|
|
61
|
+
const pendingDecisionsFor = (node) => {
|
|
62
|
+
if (node.lifecycleStatus === "completed" || node.lifecycleStatus === "archived")
|
|
63
|
+
return [];
|
|
64
|
+
const resolved = new Set(node.recentEvents.filter((event) => event.phase === "decision_resolved" && event.decisionId).map((event) => event.decisionId));
|
|
65
|
+
const missionContext = { missionObjective: node.mission.objective || undefined, repository: node.mission.repository };
|
|
66
|
+
// Only the current, mid-turn blocker belongs in the decision inbox. An
|
|
67
|
+
// unresolved event from an older/completed turn is history: the mission
|
|
68
|
+
// may remain open for optional follow-up, but it is not waiting on the
|
|
69
|
+
// user. Requiring the blocker to be the latest observed event also clears
|
|
70
|
+
// it as soon as provider activity resumes.
|
|
71
|
+
const latestEvent = node.recentEvents.at(-1);
|
|
72
|
+
const fromEvents = !node.turnCompleted && latestEvent?.phase === "blocked_on_user"
|
|
73
|
+
&& latestEvent.decisionId && !resolved.has(latestEvent.decisionId)
|
|
74
|
+
&& (!latestEvent.jobId || latestEvent.jobId === node.jobId)
|
|
75
|
+
? [{
|
|
76
|
+
id: latestEvent.decisionId, missionId: node.missionId, missionName: node.name,
|
|
77
|
+
kind: latestEvent.decisionKind ?? "question", question: latestEvent.detail, context: latestEvent.context,
|
|
78
|
+
choices: latestEvent.choices ?? [], recommendation: latestEvent.recommendation, impact: latestEvent.impact, createdAt: latestEvent.ts, ...missionContext,
|
|
79
|
+
}]
|
|
80
|
+
: [];
|
|
81
|
+
// A permission prompt parks the session mid-turn (tempo blocked, the ask
|
|
82
|
+
// in needs) with no TTY to answer it — surface it as an answerable
|
|
83
|
+
// decision. It self-clears once the job is no longer parked.
|
|
84
|
+
const latestJob = jobs.get(node.jobId);
|
|
85
|
+
const parked = !node.turnCompleted && latestJob?.tempo === "blocked" && latestJob.needs && /^approve\s/i.test(latestJob.needs) && !resolved.has(`perm:${node.jobId}`)
|
|
86
|
+
? [{
|
|
87
|
+
id: `perm:${node.jobId}`, missionId: node.missionId, missionName: node.name,
|
|
88
|
+
kind: "permission", question: latestJob.needs, context: latestJob.detail,
|
|
89
|
+
choices: ["Approve & continue", "Deny"], impact: "The session is parked at this prompt until you answer.", createdAt: node.updatedAt, ...missionContext,
|
|
90
|
+
}]
|
|
91
|
+
: [];
|
|
92
|
+
const hasStructuredPrompt = node.recentEvents.some((event) => event.jobId === node.jobId && event.phase === "blocked_on_user" && event.decisionId?.startsWith("skill-prompt:"));
|
|
93
|
+
const providerPrompt = !node.turnCompleted && latestJob?.tempo === "blocked" && latestJob.needs && (latestJob.promptChoices?.length ?? 0) > 0 && !/^approve\s/i.test(latestJob.needs) && !/rate limit|spend limit/i.test(latestJob.needs) && !hasStructuredPrompt && !resolved.has(`prompt:${node.jobId}`)
|
|
94
|
+
? [{
|
|
95
|
+
id: `prompt:${node.jobId}`, missionId: node.missionId, missionName: node.name,
|
|
96
|
+
kind: "question", question: latestJob.needs, context: latestJob.detail,
|
|
97
|
+
choices: latestJob.promptChoices ?? [], impact: "The provider is waiting for this answer before the skill can continue.", createdAt: node.updatedAt, ...missionContext,
|
|
98
|
+
}]
|
|
99
|
+
: [];
|
|
100
|
+
return [...fromEvents, ...parked, ...providerPrompt];
|
|
101
|
+
};
|
|
55
102
|
for (const [missionId, group] of groups) {
|
|
56
103
|
group.sort((a, b) => dateMs(a.job.createdAt) - dateMs(b.job.createdAt));
|
|
57
104
|
const latest = group[group.length - 1];
|
|
@@ -79,7 +126,7 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
|
|
|
79
126
|
.map((event) => [`${event.sessionId}:${event.jobId ?? ""}:${event.ts}:${event.phase}:${event.decisionId ?? ""}:${event.targetWorker ?? ""}:${event.detail}`, event])).values()]
|
|
80
127
|
.sort((a, b) => a.ts - b.ts);
|
|
81
128
|
let recentEvents = allEvents.slice(-100);
|
|
82
|
-
|
|
129
|
+
const fanWorkers = group.flatMap(({ jobId, job }) => (job.fan ?? []).filter((worker) => worker.kind === "agent" || worker.kind === "subagent").map((worker) => ({
|
|
83
130
|
...worker,
|
|
84
131
|
jobId,
|
|
85
132
|
jobState: job.state,
|
|
@@ -89,150 +136,41 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
|
|
|
89
136
|
const linkedSession = sessionsByJob.get(jobId);
|
|
90
137
|
return [...(job.sessionId ? [[job.sessionId, jobId]] : []), ...(linkedSession ? [[linkedSession.sessionId, jobId]] : [])];
|
|
91
138
|
}));
|
|
92
|
-
const jobStateById = new Map(group.map(({ jobId, job }) => [jobId, job.state]));
|
|
93
|
-
const parentTurnFinished = (jobId) => ["done", "completed", "failed", "error", "cancelled", "canceled", "stopped"].includes((jobStateById.get(jobId) ?? "").toLowerCase());
|
|
94
139
|
const eventJobId = (event) => event.jobId ?? jobIdBySession.get(event.sessionId);
|
|
95
|
-
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
// A stop without any observed launch is often a nested/background task
|
|
116
|
-
// notification. It is evidence, but not enough to invent a person.
|
|
117
|
-
if (isStop && !prior)
|
|
118
|
-
continue;
|
|
119
|
-
nativeEvidence.set(key, {
|
|
120
|
-
id: nativeId,
|
|
121
|
-
jobId: resolvedJobId,
|
|
122
|
-
label: prior?.label !== prior?.id ? prior.label : label,
|
|
123
|
-
startedAt: Math.min(prior?.startedAt ?? event.ts, event.ts),
|
|
124
|
-
doneAt: isStop ? event.ts : isStart ? undefined : prior?.doneAt,
|
|
125
|
-
updatedAt: event.ts,
|
|
126
|
-
running: isStart,
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
const workerByNativeKey = new Map(workers.map((worker) => [`${worker.jobId}:${worker.id}`, worker]));
|
|
130
|
-
for (const native of nativeEvidence.values()) {
|
|
131
|
-
const key = `${native.jobId}:${native.id}`;
|
|
132
|
-
const fanWorker = workerByNativeKey.get(key);
|
|
133
|
-
const turnFinished = parentTurnFinished(native.jobId);
|
|
134
|
-
// SubagentStop is the worker's own terminal lifecycle boundary. The
|
|
135
|
-
// manager may remain idle/open for follow-up, but that must not keep a
|
|
136
|
-
// finished worker seated indefinitely. A later SubagentStart for the
|
|
137
|
-
// same native id clears native.doneAt above and resumes that person.
|
|
138
|
-
const workerFinished = Boolean(native.doneAt) || turnFinished;
|
|
139
|
-
const merged = fanWorker ? {
|
|
140
|
-
...fanWorker,
|
|
141
|
-
label: fanWorker.label || native.label,
|
|
142
|
-
startedAt: Math.min(fanWorker.startedAt, native.startedAt),
|
|
143
|
-
doneAt: native.doneAt ?? (turnFinished ? fanWorker.doneAt : undefined),
|
|
144
|
-
updatedAt: Math.max(fanWorker.updatedAt ?? 0, native.updatedAt),
|
|
145
|
-
jobState: workerFinished ? "done" : native.running ? "working" : "idle",
|
|
146
|
-
} : {
|
|
147
|
-
id: native.id,
|
|
148
|
-
kind: "agent",
|
|
149
|
-
label: native.label,
|
|
150
|
-
startedAt: native.startedAt,
|
|
151
|
-
doneAt: native.doneAt,
|
|
152
|
-
updatedAt: native.updatedAt,
|
|
153
|
-
jobId: native.jobId,
|
|
154
|
-
jobState: workerFinished ? "done" : native.running ? "working" : "idle",
|
|
155
|
-
};
|
|
156
|
-
workerByNativeKey.set(key, merged);
|
|
140
|
+
const missionSummary = missions?.summaryFor(missionId, oldest.job.intent ?? "");
|
|
141
|
+
// One canonical person per delegated job. The resolver owns every identity
|
|
142
|
+
// decision — native ids, delegation labels, plan tasks, nested agents —
|
|
143
|
+
// and persists them so a later poll cannot re-decide them differently.
|
|
144
|
+
const resolved = resolveMissionWorkers({
|
|
145
|
+
missionId,
|
|
146
|
+
fanWorkers,
|
|
147
|
+
events: allEvents,
|
|
148
|
+
eventJobId,
|
|
149
|
+
planTasks: planStore?.get(missionId)?.phases.flatMap((phase) => allPlanTasks(phase.tasks)),
|
|
150
|
+
missionActive: (missionSummary?.lifecycleStatus ?? "active") === "active",
|
|
151
|
+
seeded: workerIdentity?.list(missionId),
|
|
152
|
+
seededNested: workerIdentity?.listNested(missionId),
|
|
153
|
+
now,
|
|
154
|
+
correlationGraceMs: workerCorrelationGraceMs,
|
|
155
|
+
});
|
|
156
|
+
let workers = resolved.workers;
|
|
157
|
+
if (workerIdentity && resolved.dirty) {
|
|
158
|
+
workerIdentity.save(missionId, resolved.records);
|
|
159
|
+
workerIdentity.saveNested(missionId, resolved.nested);
|
|
157
160
|
}
|
|
158
|
-
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
workersByIdentity.set(worker.id, worker);
|
|
168
|
-
continue;
|
|
161
|
+
// Keep the board honest without waiting for the orchestrator to republish
|
|
162
|
+
// it: a worker that is provably running advances its task to working.
|
|
163
|
+
if (planStore) {
|
|
164
|
+
// `doneAt` is the only lifecycle signal; `jobState` is decoration.
|
|
165
|
+
const present = workers.filter((worker) => !worker.doneAt);
|
|
166
|
+
for (const binding of reconcilePlanWorkers(planStore.get(missionId), present)) {
|
|
167
|
+
planStore.attachWorker(missionId, binding);
|
|
168
|
+
if (binding.inferred)
|
|
169
|
+
workerIdentity?.addTaskBinding(missionId, binding.canonicalWorker, binding.taskId);
|
|
169
170
|
}
|
|
170
|
-
const priorFreshness = prior.updatedAt ?? prior.doneAt ?? prior.startedAt;
|
|
171
|
-
const workerFreshness = worker.updatedAt ?? worker.doneAt ?? worker.startedAt;
|
|
172
|
-
const latest = workerFreshness >= priorFreshness ? worker : prior;
|
|
173
|
-
workersByIdentity.set(worker.id, {
|
|
174
|
-
...latest,
|
|
175
|
-
label: latest.label || prior.label,
|
|
176
|
-
startedAt: Math.min(prior.startedAt, worker.startedAt),
|
|
177
|
-
});
|
|
178
171
|
}
|
|
179
|
-
|
|
180
|
-
// Friendly/custom worker names are presentation aliases only. Native
|
|
181
|
-
// Claude ids remain canonical so close or repeated delegations can never
|
|
182
|
-
// swap React identities or manufacture an extra avatar.
|
|
183
|
-
const workerAliases = new Map();
|
|
184
|
-
const claimedNativeWorkers = new Set();
|
|
185
|
-
for (const event of allEvents.filter((candidate) => candidate.source === "custom" && candidate.phase === "delegating" && candidate.targetWorker)) {
|
|
186
|
-
const resolvedJobId = eventJobId(event);
|
|
187
|
-
if (!resolvedJobId)
|
|
188
|
-
continue;
|
|
189
|
-
const aliasKey = `${resolvedJobId}:${event.targetWorker}`;
|
|
190
|
-
const candidate = workers
|
|
191
|
-
.filter((worker) => worker.jobId === resolvedJobId && !claimedNativeWorkers.has(worker.id) && Math.abs(worker.startedAt - event.ts) <= 30_000)
|
|
192
|
-
.sort((a, b) => Math.abs(a.startedAt - event.ts) - Math.abs(b.startedAt - event.ts))[0];
|
|
193
|
-
workerAliases.set(aliasKey, candidate?.id ?? aliasKey);
|
|
194
|
-
if (!candidate)
|
|
195
|
-
continue;
|
|
196
|
-
claimedNativeWorkers.add(candidate.id);
|
|
197
|
-
}
|
|
198
|
-
if (workerAliases.size)
|
|
199
|
-
recentEvents = recentEvents.map((event) => {
|
|
200
|
-
if (!event.targetWorker)
|
|
201
|
-
return event;
|
|
202
|
-
const resolvedJobId = eventJobId(event);
|
|
203
|
-
if (!resolvedJobId)
|
|
204
|
-
return event;
|
|
205
|
-
const key = `${resolvedJobId}:${event.targetWorker}`;
|
|
206
|
-
const targetWorker = workerAliases.get(key);
|
|
207
|
-
return targetWorker ? { ...event, targetWorker } : event;
|
|
208
|
-
});
|
|
209
|
-
// Claude's fan file can arrive just before its delegation hook. Publishing
|
|
210
|
-
// that unmatched native id for a single poll makes the office animate a
|
|
211
|
-
// second person. Hold it briefly for correlation; canonical/event-backed
|
|
212
|
-
// workers remain immediate, and hookless workers appear after the grace.
|
|
213
|
-
const correlatedWorkerIds = new Set(recentEvents.filter((event) => event.source === "custom" && event.phase === "delegating").map((event) => event.targetWorker).filter((id) => Boolean(id)));
|
|
214
|
-
workers = workers.filter((worker) => nativeEvidence.has(`${worker.jobId}:${worker.id}`) || correlatedWorkerIds.has(worker.id) || now - worker.startedAt >= workerCorrelationGraceMs);
|
|
172
|
+
recentEvents = recentEvents.map(resolved.rewrite);
|
|
215
173
|
const derived = deriveStatus(session, latest.job, recentEvents, now);
|
|
216
|
-
const workerIds = new Set(workers.map((worker) => worker.id));
|
|
217
|
-
for (const event of recentEvents) {
|
|
218
|
-
if (!event.targetWorker || workerIds.has(event.targetWorker))
|
|
219
|
-
continue;
|
|
220
|
-
const related = recentEvents.filter((candidate) => candidate.targetWorker === event.targetWorker);
|
|
221
|
-
const start = related.find((candidate) => candidate.phase === "delegating" || candidate.phase?.toLowerCase().includes("subagentstart") || candidate.hookEventName?.toLowerCase() === "subagentstart");
|
|
222
|
-
if (!start)
|
|
223
|
-
continue;
|
|
224
|
-
// A friendly delegation target is an alias, not a person. Hold every
|
|
225
|
-
// unmatched identity through the correlation window; a native Agent id
|
|
226
|
-
// normally arrives within milliseconds and becomes the sole actor.
|
|
227
|
-
if (now - start.ts < workerCorrelationGraceMs)
|
|
228
|
-
continue;
|
|
229
|
-
const done = [...related].reverse().find((candidate) => candidate.phase === "worker_reported" || candidate.phase?.toLowerCase().includes("subagentstop") || candidate.hookEventName?.toLowerCase() === "subagentstop");
|
|
230
|
-
const resolvedJobId = eventJobId(event);
|
|
231
|
-
if (!resolvedJobId)
|
|
232
|
-
continue;
|
|
233
|
-
workers.push({ id: event.targetWorker, label: event.targetWorker, kind: "subagent", startedAt: start.ts, doneAt: done?.ts, updatedAt: related.at(-1)?.ts, jobId: resolvedJobId, jobState: done ? "done" : latest.job.state });
|
|
234
|
-
workerIds.add(event.targetWorker);
|
|
235
|
-
}
|
|
236
174
|
const derivedMessages = group.flatMap(({ jobId, job }) => {
|
|
237
175
|
const createdAt = dateMs(job.createdAt);
|
|
238
176
|
const updatedAt = dateMs(job.updatedAt) || createdAt;
|
|
@@ -274,7 +212,9 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
|
|
|
274
212
|
lifecycleStatus: "active",
|
|
275
213
|
mode: "orchestrated",
|
|
276
214
|
};
|
|
277
|
-
|
|
215
|
+
// Derived at read time rather than written back: the events are the source
|
|
216
|
+
// of truth, so this always reflects the latest ones without extra writes.
|
|
217
|
+
const missionPlan = deriveChangedFiles(planStore?.get(missionId), workers, recentEvents);
|
|
278
218
|
const inactiveForMs = Math.max(0, now - updatedAt);
|
|
279
219
|
let activityStatus = derived.status;
|
|
280
220
|
if (mission.lifecycleStatus === "completed" || mission.lifecycleStatus === "archived") {
|
|
@@ -298,8 +238,15 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
|
|
|
298
238
|
activityStatus = "stalled";
|
|
299
239
|
}
|
|
300
240
|
}
|
|
241
|
+
const name = missions?.nameFor(missionId) ?? oldest.job.name ?? latest.job.name ?? missionId;
|
|
242
|
+
const turnCompleted = latest.job.state === "done";
|
|
243
|
+
const pendingDecisions = pendingDecisionsFor({ missionId, name, jobId: latest.jobId, mission, lifecycleStatus: mission.lifecycleStatus, turnCompleted, recentEvents, updatedAt });
|
|
244
|
+
// An unanswered decision is, by definition, waiting on the user. Do not let the
|
|
245
|
+
// runtime's quiet session decay it to idle/stalled while the question stands.
|
|
246
|
+
if (mission.lifecycleStatus === "active" && pendingDecisions.length && (activityStatus === "idle" || activityStatus === "stalled"))
|
|
247
|
+
activityStatus = "waiting_on_you";
|
|
301
248
|
const runStartedAt = dateMs(latest.job.createdAt) || createdAt;
|
|
302
|
-
const alerts = deriveAlerts({ lifecycle: mission.lifecycleStatus, activity: activityStatus, stale: derived.stale, turnCompleted
|
|
249
|
+
const alerts = deriveAlerts({ lifecycle: mission.lifecycleStatus, activity: activityStatus, stale: derived.stale, turnCompleted, workers, events: recentEvents, now, turnTokens: latest.job.tokens ?? 0, runStartedAt });
|
|
303
250
|
orchestrators.push({
|
|
304
251
|
missionId,
|
|
305
252
|
threadId: missionId,
|
|
@@ -312,14 +259,11 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
|
|
|
312
259
|
latest.job.resumeSessionId ??
|
|
313
260
|
latest.jobId,
|
|
314
261
|
pid: session?.pid ?? -1,
|
|
315
|
-
name
|
|
316
|
-
oldest.job.name ??
|
|
317
|
-
latest.job.name ??
|
|
318
|
-
missionId,
|
|
262
|
+
name,
|
|
319
263
|
status: activityStatus,
|
|
320
264
|
lifecycleStatus: mission.lifecycleStatus,
|
|
321
265
|
activityStatus,
|
|
322
|
-
turnCompleted
|
|
266
|
+
turnCompleted,
|
|
323
267
|
inactiveForMs,
|
|
324
268
|
waitingFor: mission.lifecycleStatus === "completed" || mission.lifecycleStatus === "archived" ? undefined : session?.waitingFor ?? latest.job.needs,
|
|
325
269
|
worktreePath: latest.job.worktreePath ?? oldest.job.worktreePath,
|
|
@@ -362,46 +306,6 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
|
|
|
362
306
|
});
|
|
363
307
|
}
|
|
364
308
|
orchestrators.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
365
|
-
const decisions = orchestrators.flatMap((node) =>
|
|
366
|
-
if (node.lifecycleStatus === "completed" || node.lifecycleStatus === "archived")
|
|
367
|
-
return [];
|
|
368
|
-
const resolved = new Set(node.recentEvents.filter((event) => event.phase === "decision_resolved" && event.decisionId).map((event) => event.decisionId));
|
|
369
|
-
const missionContext = { missionObjective: node.mission.objective || undefined, repository: node.mission.repository };
|
|
370
|
-
// Only the current, mid-turn blocker belongs in the decision inbox. An
|
|
371
|
-
// unresolved event from an older/completed turn is history: the mission
|
|
372
|
-
// may remain open for optional follow-up, but it is not waiting on the
|
|
373
|
-
// user. Requiring the blocker to be the latest observed event also clears
|
|
374
|
-
// it as soon as provider activity resumes.
|
|
375
|
-
const latestEvent = node.recentEvents.at(-1);
|
|
376
|
-
const fromEvents = !node.turnCompleted && latestEvent?.phase === "blocked_on_user"
|
|
377
|
-
&& latestEvent.decisionId && !resolved.has(latestEvent.decisionId)
|
|
378
|
-
&& (!latestEvent.jobId || latestEvent.jobId === node.jobId)
|
|
379
|
-
? [{
|
|
380
|
-
id: latestEvent.decisionId, missionId: node.missionId, missionName: node.name,
|
|
381
|
-
kind: latestEvent.decisionKind ?? "question", question: latestEvent.detail, context: latestEvent.context,
|
|
382
|
-
choices: latestEvent.choices ?? [], recommendation: latestEvent.recommendation, impact: latestEvent.impact, createdAt: latestEvent.ts, ...missionContext,
|
|
383
|
-
}]
|
|
384
|
-
: [];
|
|
385
|
-
// A permission prompt parks the session mid-turn (tempo blocked, the ask
|
|
386
|
-
// in needs) with no TTY to answer it — surface it as an answerable
|
|
387
|
-
// decision. It self-clears once the job is no longer parked.
|
|
388
|
-
const latestJob = jobs.get(node.jobId);
|
|
389
|
-
const parked = !node.turnCompleted && latestJob?.tempo === "blocked" && latestJob.needs && /^approve\s/i.test(latestJob.needs) && !resolved.has(`perm:${node.jobId}`)
|
|
390
|
-
? [{
|
|
391
|
-
id: `perm:${node.jobId}`, missionId: node.missionId, missionName: node.name,
|
|
392
|
-
kind: "permission", question: latestJob.needs, context: latestJob.detail,
|
|
393
|
-
choices: ["Approve & continue", "Deny"], impact: "The session is parked at this prompt until you answer.", createdAt: node.updatedAt, ...missionContext,
|
|
394
|
-
}]
|
|
395
|
-
: [];
|
|
396
|
-
const hasStructuredPrompt = node.recentEvents.some((event) => event.jobId === node.jobId && event.phase === "blocked_on_user" && event.decisionId?.startsWith("skill-prompt:"));
|
|
397
|
-
const providerPrompt = !node.turnCompleted && latestJob?.tempo === "blocked" && latestJob.needs && (latestJob.promptChoices?.length ?? 0) > 0 && !/^approve\s/i.test(latestJob.needs) && !/rate limit|spend limit/i.test(latestJob.needs) && !hasStructuredPrompt && !resolved.has(`prompt:${node.jobId}`)
|
|
398
|
-
? [{
|
|
399
|
-
id: `prompt:${node.jobId}`, missionId: node.missionId, missionName: node.name,
|
|
400
|
-
kind: "question", question: latestJob.needs, context: latestJob.detail,
|
|
401
|
-
choices: latestJob.promptChoices ?? [], impact: "The provider is waiting for this answer before the skill can continue.", createdAt: node.updatedAt, ...missionContext,
|
|
402
|
-
}]
|
|
403
|
-
: [];
|
|
404
|
-
return [...fromEvents, ...parked, ...providerPrompt];
|
|
405
|
-
}).sort((a, b) => b.createdAt - a.createdAt);
|
|
309
|
+
const decisions = orchestrators.flatMap((node) => pendingDecisionsFor(node)).sort((a, b) => b.createdAt - a.createdAt);
|
|
406
310
|
return { orchestrators, decisions, other, generatedAt: Date.now() };
|
|
407
311
|
}
|