tryharness 0.7.23 → 0.7.26
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/assets/opencode-plugin.js +48 -11
- package/assets/pi-extension.ts +105 -26
- package/cli.mjs +1238 -322
- package/lib/activity.mjs +197 -40
- package/lib/adapters.mjs +337 -54
- package/lib/agentnode.mjs +175 -35
- package/lib/atlas.mjs +383 -83
- package/lib/cards.mjs +174 -40
- package/lib/claudemem.mjs +74 -18
- package/lib/commands.mjs +13 -5
- package/lib/context.mjs +409 -71
- package/lib/control-client.mjs +5 -2
- package/lib/coordinator.mjs +274 -57
- package/lib/daemon.mjs +5268 -1024
- package/lib/desktop.mjs +149 -35
- package/lib/digest.mjs +54 -13
- package/lib/environment.mjs +103 -21
- package/lib/files.mjs +246 -13
- package/lib/firewall.mjs +169 -35
- package/lib/folders.mjs +179 -36
- package/lib/frames.mjs +214 -40
- package/lib/inference.mjs +463 -126
- package/lib/learn.mjs +78 -16
- package/lib/live.mjs +286 -61
- package/lib/localmodels.mjs +424 -83
- package/lib/mcp-stdio.mjs +868 -151
- package/lib/media.mjs +138 -28
- package/lib/members.json +1 -1
- package/lib/members.mjs +35 -9
- package/lib/models.mjs +342 -80
- package/lib/network.mjs +201 -42
- package/lib/notify.mjs +49 -12
- package/lib/nudge.mjs +184 -33
- package/lib/ocr.mjs +223 -60
- package/lib/onboard.mjs +321 -70
- package/lib/parsers.mjs +132 -41
- package/lib/paths.mjs +33 -10
- package/lib/personal.mjs +51 -13
- package/lib/phones.mjs +384 -60
- package/lib/places.mjs +555 -107
- package/lib/provenance.mjs +19 -4
- package/lib/receipts.mjs +51 -11
- package/lib/redact.mjs +512 -116
- package/lib/runner.mjs +345 -55
- package/lib/runtime-install.mjs +55 -14
- package/lib/screens.mjs +154 -38
- package/lib/sealed.mjs +157 -28
- package/lib/secrets.mjs +18 -5
- package/lib/service.mjs +170 -34
- package/lib/snippets.mjs +94 -18
- package/lib/speech.mjs +688 -175
- package/lib/store.mjs +1654 -307
- package/lib/swarm.mjs +596 -117
- package/lib/t3.mjs +400 -79
- package/lib/trails.mjs +285 -61
- package/lib/ui.mjs +95 -23
- package/lib/vectors.mjs +5 -2
- package/lib/wakes.mjs +284 -68
- package/lib/watches.mjs +247 -48
- package/lib/worktrees.mjs +186 -38
- package/package.json +3 -3
package/lib/coordinator.mjs
CHANGED
|
@@ -11,14 +11,19 @@ import { MACHINE_RUNNERS, MEMBERS, nameOf } from "./members.mjs";
|
|
|
11
11
|
|
|
12
12
|
const CODING = new Set(MACHINE_RUNNERS);
|
|
13
13
|
// The agents with a read-only switch (runner.mjs, review in the roster): only they can be handed a review.
|
|
14
|
-
const REVIEWERS = new Set(
|
|
14
|
+
const REVIEWERS = new Set(
|
|
15
|
+
MEMBERS.filter((entry) => entry.review).map((entry) => entry.id)
|
|
16
|
+
);
|
|
15
17
|
const TRANSCRIPT_POSTS = 30;
|
|
16
18
|
const QUIET_KINDS = new Set(["note", "held", "round"]);
|
|
17
19
|
// The coordinator's own posts are Harness's; Harness's answer to work it was given is a result like any agent's.
|
|
18
|
-
const ownPost = (thread, post) =>
|
|
20
|
+
const ownPost = (thread, post) =>
|
|
21
|
+
post.author === thread.goal.facilitator && post.kind !== "result";
|
|
19
22
|
// Harness's part with no Harness window open waits on the board for one (daemon.mjs offerToServer); the thread goes on.
|
|
20
|
-
const waitsForWindow = (run) =>
|
|
21
|
-
|
|
23
|
+
const waitsForWindow = (run) =>
|
|
24
|
+
run.status === "open" && run.target_agent === "harness";
|
|
25
|
+
const going = (run) =>
|
|
26
|
+
["open", "claimed", "running"].includes(run.status) && !waitsForWindow(run);
|
|
22
27
|
|
|
23
28
|
// The privacy setting: the round's facts go to an agent on this machine with its tools off, which answers in the
|
|
24
29
|
// same shape Harness does. Nothing reaches Harness's server.
|
|
@@ -32,77 +37,199 @@ If the same attempt or failure keeps coming back, say so in loop and ask the nex
|
|
|
32
37
|
function localDecision(agent, facts) {
|
|
33
38
|
return new Promise((resolve, reject) => {
|
|
34
39
|
const prompt = `${LOCAL_PROMPT}\n\n${JSON.stringify(facts)}`;
|
|
35
|
-
const invocation =
|
|
36
|
-
|
|
37
|
-
|
|
40
|
+
const invocation =
|
|
41
|
+
agent === "codex"
|
|
42
|
+
? {
|
|
43
|
+
command: "codex",
|
|
44
|
+
args: [
|
|
45
|
+
"exec",
|
|
46
|
+
"--json",
|
|
47
|
+
"--sandbox",
|
|
48
|
+
"read-only",
|
|
49
|
+
"--skip-git-repo-check",
|
|
50
|
+
"-",
|
|
51
|
+
],
|
|
52
|
+
stdin: prompt,
|
|
53
|
+
}
|
|
54
|
+
: {
|
|
55
|
+
command: "claude",
|
|
56
|
+
args: ["-p", prompt, "--output-format", "json", "--tools", ""],
|
|
57
|
+
};
|
|
38
58
|
let child;
|
|
39
|
-
try {
|
|
59
|
+
try {
|
|
60
|
+
child = spawn(invocation.command, invocation.args, { cwd: os.tmpdir() });
|
|
61
|
+
} catch (error) {
|
|
62
|
+
reject(error);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
40
65
|
// An agent that exits before reading the prompt breaks the pipe; its exit code rejects below.
|
|
41
66
|
child.stdin.on("error", () => {});
|
|
42
67
|
child.stdin.end(invocation.stdin ?? "");
|
|
43
68
|
let out = "";
|
|
44
|
-
child.stdout.on("data", (chunk) => {
|
|
69
|
+
child.stdout.on("data", (chunk) => {
|
|
70
|
+
out += chunk;
|
|
71
|
+
});
|
|
45
72
|
child.on("error", reject);
|
|
46
73
|
child.on("close", (code) => {
|
|
47
|
-
if (code !== 0) {
|
|
74
|
+
if (code !== 0) {
|
|
75
|
+
reject(new Error(`${agent} exited with code ${code}`));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
48
78
|
// Claude answers one JSON object with its text in `result`; Codex streams events, its words in agent_message.
|
|
49
79
|
let text = "";
|
|
50
80
|
for (const line of out.split("\n")) {
|
|
51
81
|
try {
|
|
52
82
|
const event = JSON.parse(line);
|
|
53
83
|
if (typeof event.result === "string") text = event.result;
|
|
54
|
-
if (
|
|
84
|
+
if (
|
|
85
|
+
event.type === "item.completed" &&
|
|
86
|
+
event.item?.type === "agent_message"
|
|
87
|
+
)
|
|
88
|
+
text = event.item.text;
|
|
55
89
|
} catch {}
|
|
56
90
|
}
|
|
57
91
|
const match = text.match(/\{[\s\S]*\}/);
|
|
58
|
-
try {
|
|
92
|
+
try {
|
|
93
|
+
resolve(JSON.parse(match ? match[0] : text));
|
|
94
|
+
} catch {
|
|
95
|
+
reject(new Error(`${agent} did not answer in the agreed shape`));
|
|
96
|
+
}
|
|
59
97
|
});
|
|
60
98
|
});
|
|
61
99
|
}
|
|
62
100
|
|
|
63
|
-
export function createCoordinator({
|
|
101
|
+
export function createCoordinator({
|
|
102
|
+
store,
|
|
103
|
+
mode,
|
|
104
|
+
askHarness,
|
|
105
|
+
connected,
|
|
106
|
+
serverHas,
|
|
107
|
+
roster,
|
|
108
|
+
handOn,
|
|
109
|
+
localAgents,
|
|
110
|
+
pauseGoal,
|
|
111
|
+
notifyBoard,
|
|
112
|
+
searchContext,
|
|
113
|
+
log,
|
|
114
|
+
frame = () => null,
|
|
115
|
+
}) {
|
|
64
116
|
const busy = new Set();
|
|
65
117
|
const again = new Set();
|
|
66
|
-
const stateOf = (goalId) =>
|
|
67
|
-
|
|
118
|
+
const stateOf = (goalId) =>
|
|
119
|
+
JSON.parse(
|
|
120
|
+
store.meta(`coordinator:${goalId}`) ||
|
|
121
|
+
'{"lastSeenSeq":0,"rounds":0,"withoutProgress":0,"lastRoundAt":null,"lastError":null}'
|
|
122
|
+
);
|
|
123
|
+
const saveState = (goalId, state) =>
|
|
124
|
+
store.meta(`coordinator:${goalId}`, JSON.stringify(state));
|
|
68
125
|
|
|
69
126
|
// What this machine knows that the round needs, whole: the transcript, who can run, and progress since last time.
|
|
70
127
|
const facts = async (thread, state) => {
|
|
71
|
-
const posts = store
|
|
72
|
-
|
|
128
|
+
const posts = store
|
|
129
|
+
.roomPosts(thread.id, 0, 500)
|
|
130
|
+
.filter((post) => !QUIET_KINDS.has(post.kind));
|
|
131
|
+
const transcript = posts.slice(-TRANSCRIPT_POSTS).map((post) => ({
|
|
132
|
+
seq: post.seq,
|
|
133
|
+
author: post.author,
|
|
134
|
+
kind: post.kind,
|
|
135
|
+
text: post.text,
|
|
136
|
+
}));
|
|
73
137
|
const runs = store.threadRuns(thread.id);
|
|
74
138
|
const since = state.lastRoundAt ?? "";
|
|
75
|
-
const runsFinished = runs.filter(
|
|
76
|
-
|
|
77
|
-
|
|
139
|
+
const runsFinished = runs.filter(
|
|
140
|
+
(run) => run.reported_at && run.reported_at > since
|
|
141
|
+
).length;
|
|
142
|
+
const newResults = posts.filter(
|
|
143
|
+
(post) =>
|
|
144
|
+
post.seq > state.lastSeenSeq &&
|
|
145
|
+
post.author !== "user" &&
|
|
146
|
+
!ownPost(thread, post)
|
|
147
|
+
).length;
|
|
148
|
+
const failedJobs = runs
|
|
149
|
+
.filter((run) => run.status === "failed")
|
|
150
|
+
.slice(-5)
|
|
151
|
+
.map((run) => ({
|
|
152
|
+
agent: run.target_agent || "agent",
|
|
153
|
+
objective: run.objective.slice(0, 500),
|
|
154
|
+
result: String(run.result || "").slice(0, 4_000),
|
|
155
|
+
}));
|
|
78
156
|
const waiting = runs.filter(waitsForWindow);
|
|
79
157
|
// Every agent the person can ask, each with its card (cards.mjs); a coding agent only where the thread has a folder for it
|
|
80
158
|
// to work in, and Harness not while its part here waits for a window.
|
|
81
159
|
// Each card's receipts go only to a Harness that takes them (the same release as frames); an older one would refuse the round.
|
|
82
160
|
const framesTaken = mode() === "local" || serverHas("swarm_frame");
|
|
83
|
-
const candidates = roster()
|
|
84
|
-
.
|
|
161
|
+
const candidates = roster()
|
|
162
|
+
.filter(
|
|
163
|
+
(candidate) =>
|
|
164
|
+
(!CODING.has(candidate.id) || thread.goal.workspace) &&
|
|
165
|
+
!(candidate.id === "harness" && waiting.length)
|
|
166
|
+
)
|
|
167
|
+
.map((candidate) =>
|
|
168
|
+
framesTaken || !candidate.record?.checks
|
|
169
|
+
? candidate
|
|
170
|
+
: { ...candidate, record: { ...candidate.record, checks: undefined } }
|
|
171
|
+
);
|
|
85
172
|
// Earlier words close in meaning to the newest post that the transcript no longer carries, from this thread and the
|
|
86
173
|
// person's sessions on this machine.
|
|
87
|
-
const newest = posts
|
|
174
|
+
const newest = posts
|
|
175
|
+
.filter((post) => post.seq > state.lastSeenSeq)
|
|
176
|
+
.map((post) => post.text)
|
|
177
|
+
.join("\n")
|
|
178
|
+
.slice(0, 2_000);
|
|
88
179
|
const shown = new Set(transcript.map((post) => post.text));
|
|
89
180
|
let related = [];
|
|
90
181
|
if (newest.trim()) {
|
|
91
|
-
const found = await searchContext(thread.id, newest, 6).catch(() => ({
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
182
|
+
const found = await searchContext(thread.id, newest, 6).catch(() => ({
|
|
183
|
+
entries: [],
|
|
184
|
+
}));
|
|
185
|
+
related = found.entries
|
|
186
|
+
.filter((entry) => !shown.has(entry.text))
|
|
187
|
+
.slice(0, 4)
|
|
188
|
+
.map((entry) => ({
|
|
189
|
+
source: `this thread, ${entry.author}`,
|
|
190
|
+
text: entry.text.slice(0, 4_000),
|
|
191
|
+
}));
|
|
192
|
+
for (const hit of store.search({
|
|
193
|
+
query: newest.split(/\s+/).slice(0, 12).join(" "),
|
|
194
|
+
limit: 3,
|
|
195
|
+
})) {
|
|
196
|
+
if (hit.excerpt)
|
|
197
|
+
related.push({
|
|
198
|
+
source: `${nameOf(hit.agent)} session "${String(hit.title || "").slice(0, 120)}"`,
|
|
199
|
+
text: String(hit.excerpt).slice(0, 4_000),
|
|
200
|
+
});
|
|
95
201
|
}
|
|
96
202
|
}
|
|
97
203
|
// The thread's task frame (frames.mjs): each run's receipt, other threads in the repository, its state.
|
|
98
204
|
const framed = frame(thread.id);
|
|
99
205
|
return {
|
|
100
206
|
...(framed ? { frame: framed } : {}),
|
|
101
|
-
thread: {
|
|
102
|
-
|
|
103
|
-
|
|
207
|
+
thread: {
|
|
208
|
+
id: thread.id,
|
|
209
|
+
title: thread.objective,
|
|
210
|
+
folder: thread.goal.workspace,
|
|
211
|
+
agents: thread.goal.team.filter(
|
|
212
|
+
(agent) => agent !== thread.goal.facilitator
|
|
213
|
+
),
|
|
214
|
+
},
|
|
215
|
+
transcript,
|
|
216
|
+
lastSeenSeq: state.lastSeenSeq,
|
|
217
|
+
candidates,
|
|
218
|
+
progress: {
|
|
219
|
+
runsFinished,
|
|
220
|
+
newResults,
|
|
221
|
+
stillRunning: runs.filter(going).length,
|
|
222
|
+
roundsWithoutProgress: state.withoutProgress,
|
|
223
|
+
failedJobs,
|
|
104
224
|
// A Harness that does not know the field would refuse the round: it goes only to one that takes Harness's tasks.
|
|
105
|
-
...(waiting.length && (mode() === "local" || serverHas("harness_task"))
|
|
225
|
+
...(waiting.length && (mode() === "local" || serverHas("harness_task"))
|
|
226
|
+
? {
|
|
227
|
+
waitingForWindow: waiting
|
|
228
|
+
.slice(-10)
|
|
229
|
+
.map((run) => ({ objective: run.objective.slice(0, 500) })),
|
|
230
|
+
}
|
|
231
|
+
: {}),
|
|
232
|
+
},
|
|
106
233
|
related: related.slice(0, 8),
|
|
107
234
|
};
|
|
108
235
|
};
|
|
@@ -110,16 +237,52 @@ export function createCoordinator({ store, mode, askHarness, connected, serverHa
|
|
|
110
237
|
// Carry a decision out under the thread's rules. A brief from the coordinator is agent-written work inside the
|
|
111
238
|
// thread, so it runs on the thread's authority the way a goal's work does; everything else only posts.
|
|
112
239
|
const apply = (thread, decision, coordinator, candidates) => {
|
|
113
|
-
if (decision.openItem)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
240
|
+
if (decision.openItem)
|
|
241
|
+
store.roomPost({
|
|
242
|
+
goalId: thread.id,
|
|
243
|
+
author: coordinator,
|
|
244
|
+
kind: "open_item",
|
|
245
|
+
text: String(decision.openItem).slice(0, 2_000),
|
|
246
|
+
});
|
|
247
|
+
if (decision.action === "pause") {
|
|
248
|
+
pauseGoal(
|
|
249
|
+
thread.id,
|
|
250
|
+
String(
|
|
251
|
+
decision.text || "the coordinator could not go on without you"
|
|
252
|
+
).slice(0, 300)
|
|
253
|
+
);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (decision.action === "ask_person" && decision.text)
|
|
257
|
+
store.roomPost({
|
|
258
|
+
goalId: thread.id,
|
|
259
|
+
author: coordinator,
|
|
260
|
+
kind: "question",
|
|
261
|
+
text: String(decision.text).slice(0, 4_000),
|
|
262
|
+
});
|
|
263
|
+
if (
|
|
264
|
+
decision.action === "hand_on" &&
|
|
265
|
+
candidates.some((candidate) => candidate.id === decision.agent) &&
|
|
266
|
+
decision.text
|
|
267
|
+
) {
|
|
268
|
+
if (!thread.goal.team.includes(decision.agent))
|
|
269
|
+
store.setGoalTeam(thread.id, [...thread.goal.team, decision.agent]);
|
|
270
|
+
const review =
|
|
271
|
+
decision.mode === "review" && REVIEWERS.has(decision.agent);
|
|
119
272
|
const text = String(decision.text).slice(0, 4_000);
|
|
120
273
|
// The brief is posted for everyone to read and the agent is woken here, so a review runs with its read-only switch.
|
|
121
|
-
store.roomPost({
|
|
122
|
-
|
|
274
|
+
store.roomPost({
|
|
275
|
+
goalId: thread.id,
|
|
276
|
+
author: coordinator,
|
|
277
|
+
kind: "post",
|
|
278
|
+
text: `@${decision.agent} ${review ? "(review, no changes) " : ""}${text}`,
|
|
279
|
+
});
|
|
280
|
+
handOn(
|
|
281
|
+
thread.id,
|
|
282
|
+
decision.agent,
|
|
283
|
+
`${coordinator} asked you: ${text.slice(0, 300)}`,
|
|
284
|
+
{ review, words: text }
|
|
285
|
+
);
|
|
123
286
|
}
|
|
124
287
|
notifyBoard(store.getTask(thread.id));
|
|
125
288
|
};
|
|
@@ -128,52 +291,106 @@ export function createCoordinator({ store, mode, askHarness, connected, serverHa
|
|
|
128
291
|
const thread = store.getTask(goalId);
|
|
129
292
|
if (!thread || thread.kind !== "goal" || thread.status !== "open") return;
|
|
130
293
|
const state = stateOf(goalId);
|
|
131
|
-
const posts = store
|
|
294
|
+
const posts = store
|
|
295
|
+
.roomPosts(goalId, state.lastSeenSeq, 500)
|
|
296
|
+
.filter((post) => !QUIET_KINDS.has(post.kind) && !ownPost(thread, post));
|
|
132
297
|
const active = store.threadRuns(goalId).some(going);
|
|
133
298
|
if (!posts.length || active) return;
|
|
134
299
|
// A task's own room is coordinated once it becomes a conversation: someone posted in it, not only the task and its result.
|
|
135
|
-
if (
|
|
300
|
+
if (
|
|
301
|
+
store.meta(`task-room:${goalId}`) &&
|
|
302
|
+
!store.roomPosts(goalId, 0, 500).some((post) => post.kind === "post")
|
|
303
|
+
)
|
|
304
|
+
return;
|
|
136
305
|
// A thread started through Harness by an agent that runs elsewhere gets no rounds until the person sets a spend ceiling.
|
|
137
306
|
if (store.meta(`hosted-thread:${goalId}`)) return;
|
|
138
307
|
const local = mode() === "local";
|
|
139
308
|
if (!local && !connected()) return;
|
|
140
|
-
const localAgent = local
|
|
309
|
+
const localAgent = local
|
|
310
|
+
? localAgents().find(
|
|
311
|
+
(agent) => agent === "claude-code" || agent === "codex"
|
|
312
|
+
)
|
|
313
|
+
: null;
|
|
141
314
|
if (local && !localAgent) return;
|
|
142
315
|
const gathered = await facts(thread, state);
|
|
143
|
-
const lastSeq =
|
|
144
|
-
|
|
316
|
+
const lastSeq =
|
|
317
|
+
store.roomPosts(goalId, 0, 500).at(-1)?.seq ?? state.lastSeenSeq;
|
|
318
|
+
const progressed =
|
|
319
|
+
gathered.progress.runsFinished > 0 || gathered.progress.newResults > 0;
|
|
145
320
|
const startedAt = Date.now();
|
|
146
|
-
let decision = null,
|
|
321
|
+
let decision = null,
|
|
322
|
+
error = null;
|
|
147
323
|
try {
|
|
148
324
|
if (local) decision = await localDecision(localAgent, gathered);
|
|
149
325
|
else {
|
|
150
326
|
// No deadline of its own: the round ends when Harness answers or the connection drops.
|
|
151
327
|
const reply = await askHarness("coordinate", gathered, null);
|
|
152
|
-
if (!reply.ok)
|
|
328
|
+
if (!reply.ok)
|
|
329
|
+
throw new Error(
|
|
330
|
+
reply.error || "Harness did not coordinate this round"
|
|
331
|
+
);
|
|
153
332
|
decision = reply.result ?? reply;
|
|
154
333
|
}
|
|
155
|
-
} catch (failure) {
|
|
156
|
-
|
|
334
|
+
} catch (failure) {
|
|
335
|
+
error = failure.message;
|
|
336
|
+
}
|
|
337
|
+
const next = {
|
|
338
|
+
...state,
|
|
339
|
+
lastSeenSeq: lastSeq,
|
|
340
|
+
rounds: state.rounds + 1,
|
|
341
|
+
withoutProgress: progressed ? 0 : state.withoutProgress + 1,
|
|
342
|
+
lastRoundAt: new Date().toISOString(),
|
|
343
|
+
lastError: error,
|
|
344
|
+
};
|
|
157
345
|
saveState(goalId, next);
|
|
158
|
-
store.recordRound({
|
|
159
|
-
|
|
346
|
+
store.recordRound({
|
|
347
|
+
goalId,
|
|
348
|
+
coordinator: local ? localAgent : "harness",
|
|
349
|
+
action: decision?.action ?? null,
|
|
350
|
+
agent: decision?.agent ?? null,
|
|
351
|
+
loop: decision?.loop ?? null,
|
|
352
|
+
judgments: decision?.judgments ?? null,
|
|
353
|
+
progress: gathered.progress,
|
|
354
|
+
estimatedMicroUsd: decision?.estimatedMicroUsd ?? null,
|
|
355
|
+
elapsedMs: Date.now() - startedAt,
|
|
356
|
+
error,
|
|
357
|
+
});
|
|
160
358
|
if (error) {
|
|
161
359
|
log(`coordinator round for ${goalId}: ${error}`);
|
|
162
|
-
if (error !== state.lastError)
|
|
360
|
+
if (error !== state.lastError)
|
|
361
|
+
store.roomPost({
|
|
362
|
+
goalId,
|
|
363
|
+
author: thread.goal.facilitator,
|
|
364
|
+
kind: "note",
|
|
365
|
+
text: `Harness could not coordinate: ${error}`,
|
|
366
|
+
});
|
|
163
367
|
notifyBoard(store.getTask(goalId));
|
|
164
368
|
return;
|
|
165
369
|
}
|
|
166
|
-
apply(
|
|
370
|
+
apply(
|
|
371
|
+
store.getTask(goalId),
|
|
372
|
+
decision,
|
|
373
|
+
thread.goal.facilitator,
|
|
374
|
+
gathered.candidates
|
|
375
|
+
);
|
|
167
376
|
};
|
|
168
377
|
|
|
169
378
|
/** A thread changed: run a round once it is quiet; one at a time per thread, again if more came in meanwhile. */
|
|
170
379
|
const poke = (goalId) => {
|
|
171
|
-
if (busy.has(goalId)) {
|
|
380
|
+
if (busy.has(goalId)) {
|
|
381
|
+
again.add(goalId);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
172
384
|
busy.add(goalId);
|
|
173
|
-
setImmediate(
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
385
|
+
setImmediate(
|
|
386
|
+
() =>
|
|
387
|
+
void round(goalId)
|
|
388
|
+
.catch((failure) => log(`coordinator ${goalId}: ${failure.message}`))
|
|
389
|
+
.finally(() => {
|
|
390
|
+
busy.delete(goalId);
|
|
391
|
+
if (again.delete(goalId)) poke(goalId);
|
|
392
|
+
})
|
|
393
|
+
);
|
|
177
394
|
};
|
|
178
395
|
return { poke, state: stateOf };
|
|
179
396
|
}
|