tryharness 0.7.26 → 0.7.29

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/lib/store.mjs CHANGED
@@ -861,11 +861,15 @@ export function openStore(file) {
861
861
  if (status)
862
862
  execute(
863
863
  "run",
864
- "update board_tasks set status = ?, result = coalesce(?, result), reported_at = case when ? in ('done', 'failed') then ? else reported_at end, updated_at = ?, version = version + 1 where id = ?",
864
+ // An open goal belongs to no one: a claim left from before goals refused claims is cleared when it opens.
865
+ "update board_tasks set status = ?, result = coalesce(?, result), reported_at = case when ? in ('done', 'failed') then ? else reported_at end, claimed_by = case when ? = 'open' then null else claimed_by end, holder = case when ? = 'open' then null else holder end, lease_until = case when ? = 'open' then null else lease_until end, updated_at = ?, version = version + 1 where id = ?",
865
866
  status,
866
867
  result ?? null,
867
868
  status,
868
869
  now,
870
+ status,
871
+ status,
872
+ status,
869
873
  now,
870
874
  id
871
875
  );
@@ -1031,6 +1035,25 @@ export function openStore(file) {
1031
1035
  ).n
1032
1036
  );
1033
1037
  },
1038
+ /** The room's held posts, oldest first, without their words (what held them may be a secret). */
1039
+ heldPosts(goalId) {
1040
+ return execute(
1041
+ "all",
1042
+ "select id, seq, author, created_at from board_contributions where task_id = ? and kind = 'held' order by seq asc",
1043
+ goalId
1044
+ );
1045
+ },
1046
+ /** The room's contribution just before a post, held ones included. */
1047
+ lastContribution(goalId, beforeSeq) {
1048
+ return (
1049
+ execute(
1050
+ "get",
1051
+ "select id, seq, kind, author from board_contributions where task_id = ? and seq < ? order by seq desc limit 1",
1052
+ goalId,
1053
+ beforeSeq
1054
+ ) ?? null
1055
+ );
1056
+ },
1034
1057
  roomCount(goalId) {
1035
1058
  return Number(
1036
1059
  execute(
@@ -1153,6 +1176,36 @@ export function openStore(file) {
1153
1176
  progress: JSON.parse(row.progress),
1154
1177
  }));
1155
1178
  },
1179
+ /**
1180
+ * How the coordinator held up, a day at a time, newest first: rounds Harness decided, rounds a backup on this
1181
+ * computer decided after Harness could not, rounds decided here on the privacy setting, and rounds no one decided.
1182
+ */
1183
+ roundHealth(goalId = null, days = 14) {
1184
+ const since = new Date(
1185
+ Date.now() - Math.max(1, Number(days) || 14) * 86_400_000
1186
+ ).toISOString();
1187
+ return execute(
1188
+ "all",
1189
+ `select substr(at, 1, 10) as day,
1190
+ count(*) as rounds,
1191
+ sum(case when action is not null and coordinator = 'harness' then 1 else 0 end) as harness,
1192
+ sum(case when action is not null and coordinator != 'harness' and error is not null then 1 else 0 end) as backup,
1193
+ sum(case when action is not null and coordinator != 'harness' and error is null then 1 else 0 end) as local,
1194
+ sum(case when action is null then 1 else 0 end) as undecided
1195
+ from swarm_rounds where at >= ? and (? is null or goal_id = ?)
1196
+ group by day order by day desc`,
1197
+ since,
1198
+ goalId,
1199
+ goalId
1200
+ ).map((row) => ({
1201
+ day: row.day,
1202
+ rounds: Number(row.rounds),
1203
+ harness: Number(row.harness),
1204
+ backup: Number(row.backup),
1205
+ local: Number(row.local),
1206
+ undecided: Number(row.undecided),
1207
+ }));
1208
+ },
1156
1209
  // A thread's runs (wakes and jobs), oldest first, for its transcript.
1157
1210
  threadRuns(goalId) {
1158
1211
  return execute(
@@ -1307,6 +1360,17 @@ export function openStore(file) {
1307
1360
  usage: row.usage ? JSON.parse(row.usage) : null,
1308
1361
  }));
1309
1362
  },
1363
+ /** The run working in an agent's session right now, if any: a session a running task holds. */
1364
+ runHoldingSession(agent, sessionId) {
1365
+ return (
1366
+ execute(
1367
+ "get",
1368
+ "select id, parent_id from board_tasks where target_agent = ? and status in ('claimed', 'running') and session_id is not null and instr(session_id, ?) > 0 order by updated_at desc limit 1",
1369
+ agent,
1370
+ String(sessionId)
1371
+ ) ?? null
1372
+ );
1373
+ },
1310
1374
  setRunSession(id, sessionId) {
1311
1375
  execute(
1312
1376
  "run",
@@ -1606,6 +1670,12 @@ export function openStore(file) {
1606
1670
  String(id)
1607
1671
  );
1608
1672
  },
1673
+ presentFiles() {
1674
+ return execute(
1675
+ "all",
1676
+ "select id from board_files where present = 1 order by created_at asc"
1677
+ ).map((row) => this.file(row.id));
1678
+ },
1609
1679
  taskFiles(taskId) {
1610
1680
  return execute(
1611
1681
  "all",
@@ -2250,6 +2320,14 @@ export function openStore(file) {
2250
2320
  error:
2251
2321
  "a task for several agents is worked through its parts; claim the part posted for you",
2252
2322
  };
2323
+ // A goal is a room the team works in, not a task to take: a claimed goal turned away the claimer's own posts and
2324
+ // could not be released (October 14, Codex in the keeper thread).
2325
+ if (this.getTask(id)?.kind === "goal")
2326
+ return {
2327
+ ok: false,
2328
+ error:
2329
+ "a goal is a room, not a task: read it with read_room and post with post_to_room; claim only a task posted for you",
2330
+ };
2253
2331
  const now = new Date().toISOString();
2254
2332
  const leaseUntil =
2255
2333
  leaseMinutes == null
package/lib/swarm.mjs CHANGED
@@ -34,6 +34,8 @@ export const PROCESS_NAMES = {
34
34
  opencode: "opencode",
35
35
  };
36
36
  const SAY_LIMIT = 4_000;
37
+ // `@swarm` in the person's message: written to their whole swarm, not to one agent.
38
+ const SWARM = /(^|\s)@swarm\b/;
37
39
  const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
38
40
 
39
41
  // The id an agent resumes a session by: the index keys sessions by their file name.
@@ -102,7 +104,27 @@ export function createSwarm({
102
104
  runsIn,
103
105
  threadFiles = () => ({ files: [], rendering: [] }),
104
106
  whyWaiting = () => null,
107
+ goals = null,
108
+ reofferHarness = () => {},
105
109
  }) {
110
+ // What the person let a thread spend of their Harness credits, in dollars, or null for no limit of its own.
111
+ const ceilingOf = (threadId) => {
112
+ const limit = Number(store.meta(`spend-ceiling:${threadId}`));
113
+ return store.meta(`spend-ceiling:${threadId}`) && Number.isFinite(limit)
114
+ ? limit
115
+ : null;
116
+ };
117
+ const setCeiling = (threadId, usd) =>
118
+ store.meta(
119
+ `spend-ceiling:${threadId}`,
120
+ usd === null ? "" : String(Math.round(usd * 100) / 100)
121
+ );
122
+ // A limit as the person gave it: dollars, at least zero; "off" or nothing for none.
123
+ const ceilingFrom = (value) => {
124
+ if (value === undefined || value === null || value === "off") return null;
125
+ const usd = Number(value);
126
+ return Number.isFinite(usd) && usd >= 0 ? usd : undefined;
127
+ };
106
128
  const awaitingStart = (run) =>
107
129
  run.status === "open" &&
108
130
  Boolean(run.run_workspace) &&
@@ -114,12 +136,30 @@ export function createSwarm({
114
136
  const agentsOf = (thread) =>
115
137
  thread.goal.team.filter((agent) => agent !== thread.goal.facilitator);
116
138
 
117
- // Who a message is for: the agents it names, else the only agent in the thread, else whoever coordinates.
139
+ // Who a message is for: the agents it names, else whoever coordinates, which brings in every agent with a part in it.
140
+ // Where the coordinator cannot assemble (the privacy setting, a Harness without it) the thread's only agent takes it.
141
+ // `@swarm` is for the whole swarm (the agents it names hear it from the post itself).
118
142
  const addressed = (thread, text) => {
143
+ if (SWARM.test(text)) return [thread.goal.facilitator];
119
144
  const named = mentionsIn(text, thread.goal.team);
120
145
  if (named.length) return named;
121
146
  const agents = agentsOf(thread);
122
- return agents.length === 1 ? agents : [thread.goal.facilitator];
147
+ return agents.length === 1 && !coordinator.assembles()
148
+ ? agents
149
+ : [thread.goal.facilitator];
150
+ };
151
+ // The person's post is for the swarm when it says `@swarm`, or when it falls to the coordinator: then the coordinator
152
+ // brings in every agent with a part in it at once (coordinator.mjs heard). A message to one agent is that agent's.
153
+ const heardBy = (thread, post, text) => {
154
+ if (!post?.seq || post.kind !== "post") return;
155
+ const swarm = SWARM.test(text);
156
+ if (!swarm && addressed(thread, text)[0] !== thread.goal.facilitator)
157
+ return;
158
+ coordinator.heard(thread.id, {
159
+ seq: post.seq,
160
+ swarm,
161
+ named: mentionsIn(text, roomAgents),
162
+ });
123
163
  };
124
164
 
125
165
  // What the thread waits on the person for, if anything: a pause, an agent's question, a run's question.
@@ -217,7 +257,20 @@ export function createSwarm({
217
257
  coordination: {
218
258
  mode: coordinator.mode(),
219
259
  rounds: coordinator.state(thread.id).rounds,
260
+ // Who decided this thread's rounds: Harness, a backup here after Harness could not, this computer, no one.
261
+ decided: store.roundHealth(thread.id, 3650).reduce(
262
+ (sum, day) => ({
263
+ harness: sum.harness + day.harness,
264
+ backup: sum.backup + day.backup,
265
+ local: sum.local + day.local,
266
+ undecided: sum.undecided + day.undecided,
267
+ }),
268
+ { harness: 0, backup: 0, local: 0, undecided: 0 }
269
+ ),
220
270
  },
271
+ // The goal as the swarm keeps it (goals.mjs): what the person wants, its checks, where it stands, every version.
272
+ goal: goals?.record(thread.id) ?? null,
273
+ ceilingUsd: ceilingOf(thread.id),
221
274
  changes: worktrees.summary(thread.id),
222
275
  };
223
276
  };
@@ -312,6 +365,15 @@ export function createSwarm({
312
365
  thread: summary(thread),
313
366
  posts: store.roomPosts(thread.id, 0, 500),
314
367
  runs,
368
+ // Posts held for the person (who wrote one, when, never its words), and the post that replaced each one an agent
369
+ // posted again.
370
+ held: store.heldPosts(thread.id).map((post) => ({
371
+ id: post.id,
372
+ seq: post.seq,
373
+ author: post.author,
374
+ at: post.created_at,
375
+ replacedBy: store.meta(`held-replaced:${post.id}`) || null,
376
+ })),
315
377
  ...threadFiles(thread, hostedCaller),
316
378
  };
317
379
  };
@@ -328,6 +390,7 @@ export function createSwarm({
328
390
  store.setGoalTeam(thread.id, [...thread.goal.team, ...joining]);
329
391
  if (thread.status !== "open") {
330
392
  store.setGoalState(thread.id, { status: "open" });
393
+ goals?.reopen(thread.id);
331
394
  store.roomPost({
332
395
  goalId: thread.id,
333
396
  author: "user",
@@ -336,7 +399,7 @@ export function createSwarm({
336
399
  });
337
400
  }
338
401
  const current = store.getTask(thread.id);
339
- return goalCommand(
402
+ const posted = goalCommand(
340
403
  {
341
404
  command: "board.room.post",
342
405
  id: thread.id,
@@ -345,6 +408,9 @@ export function createSwarm({
345
408
  },
346
409
  "user"
347
410
  );
411
+ if (!to && posted?.ok && !posted.paused)
412
+ heardBy(current, posted.post, text);
413
+ return posted;
348
414
  };
349
415
 
350
416
  return function swarmCommand(request, agent) {
@@ -576,6 +642,14 @@ export function createSwarm({
576
642
  request.limit
577
643
  ),
578
644
  };
645
+ if (command === "board.swarm.health")
646
+ return {
647
+ ok: true,
648
+ days: store.roundHealth(
649
+ request.id ? String(request.id) : null,
650
+ request.days
651
+ ),
652
+ };
579
653
  if (command === "board.swarm.coordinator") {
580
654
  if (request.mode === "harness" || request.mode === "local")
581
655
  coordinator.setMode(request.mode);
@@ -596,6 +670,10 @@ export function createSwarm({
596
670
  .trim()
597
671
  .slice(0, 20_000);
598
672
  if (!text) return { ok: false, error: "a thread starts with a message" };
673
+ // What it may spend of the person's Harness credits, set once as it starts (owner, October 15).
674
+ const ceiling = ceilingFrom(request.ceilingUsd);
675
+ if (ceiling === undefined)
676
+ return { ok: false, error: "a limit is a number of dollars, or off" };
599
677
  const from =
600
678
  request.continueSession && typeof request.continueSession === "object"
601
679
  ? request.continueSession
@@ -662,6 +740,7 @@ export function createSwarm({
662
740
  maxPosts: defaults.maxPosts,
663
741
  maxWakes: defaults.maxWakes,
664
742
  });
743
+ if (ceiling !== null) setCeiling(thread.id, ceiling);
665
744
  if (from) {
666
745
  store.setGoalSession(
667
746
  thread.id,
@@ -679,6 +758,7 @@ export function createSwarm({
679
758
  mentions: addressed(opened, text),
680
759
  });
681
760
  wakeFor(thread.id, first, "user");
761
+ if (!to) heardBy(opened, first, text);
682
762
  notifyBoard(store.getTask(thread.id));
683
763
  return { ok: true, thread: summary(store.getTask(thread.id)) };
684
764
  };
@@ -772,6 +852,51 @@ export function createSwarm({
772
852
  stopThreadRuns(thread.id);
773
853
  return { ok: true, thread: summary(store.getTask(thread.id)) };
774
854
  }
855
+ // What the thread may spend of the person's Harness credits, set or cleared by the person; a task waiting on the old
856
+ // limit is offered again.
857
+ if (command === "board.swarm.ceiling") {
858
+ if (agent !== "user")
859
+ return { ok: false, error: "only you set what a thread may spend" };
860
+ const usd = ceilingFrom(request.usd);
861
+ if (usd === undefined)
862
+ return { ok: false, error: "a limit is a number of dollars, or off" };
863
+ setCeiling(thread.id, usd);
864
+ store.roomPost({
865
+ goalId: thread.id,
866
+ author: "user",
867
+ kind: "note",
868
+ text:
869
+ usd === null
870
+ ? "You took this thread's own spending limit off."
871
+ : `You set what this thread may spend of your Harness credits to $${usd.toFixed(2)}.`,
872
+ });
873
+ reofferHarness();
874
+ notifyBoard(store.getTask(thread.id));
875
+ return { ok: true, thread: summary(store.getTask(thread.id)) };
876
+ }
877
+ // The goal in the person's own words, or the lead's (the thread's coordinator): a new version, kept with the rest.
878
+ if (command === "board.swarm.goal") {
879
+ if (agent !== "user" && agent !== thread.goal.facilitator)
880
+ return {
881
+ ok: false,
882
+ error: `only you or ${thread.goal.facilitator} set this thread's goal`,
883
+ };
884
+ if (!goals) return { ok: false, error: "this daemon keeps no goals" };
885
+ const set = goals.set(
886
+ thread.id,
887
+ { statement: request.statement, criteria: request.criteria },
888
+ {
889
+ by: agent,
890
+ seq: store.roomPosts(thread.id, 0, 500).at(-1)?.seq ?? null,
891
+ }
892
+ );
893
+ if (!set)
894
+ return {
895
+ ok: false,
896
+ error: "a goal needs a statement and at least one check",
897
+ };
898
+ return { ok: true, thread: summary(store.getTask(thread.id)) };
899
+ }
775
900
  if (command === "board.swarm.resume") {
776
901
  if (thread.status !== "paused")
777
902
  return { ok: false, error: `the thread is ${thread.status}` };
package/lib/t3.mjs CHANGED
@@ -447,12 +447,19 @@ export function runInT3(run, emit, observe) {
447
447
  stopped = true;
448
448
  if (threadId) void interrupt();
449
449
  });
450
- const finish = (kind, text) => {
450
+ const letGo = () => {
451
451
  clearTimeout(timer);
452
452
  handed.delete(run.runId);
453
453
  steerable.delete(run.runId);
454
454
  release();
455
- emit(kind, text.slice(0, 20_000), { usage: null });
455
+ };
456
+ // What the turn used, as T3 recorded it, so a turn there counts toward its thread's spend like one run here.
457
+ const finish = (kind, text) => {
458
+ letGo();
459
+ emit(kind, text.slice(0, 20_000), {
460
+ usage:
461
+ threadId && runId ? (run.usageOf?.(threadId, runId) ?? null) : null,
462
+ });
456
463
  };
457
464
  const read = async () => {
458
465
  try {
@@ -582,6 +589,13 @@ export function runInT3(run, emit, observe) {
582
589
  if (stopped) await interrupt();
583
590
  void read();
584
591
  } catch (error) {
592
+ // T3 Code did not answer before the turn began, though it was there a moment before (October 14 and 15: twice
593
+ // a turn failed this way while T3 was running): where the caller can run the agent itself, it does.
594
+ if (error.code === "not_running" && !runId && run.fallback && !stopped) {
595
+ letGo();
596
+ run.fallback(error.message);
597
+ return;
598
+ }
585
599
  finish("failed", `T3 Code did not take it: ${error.message}`);
586
600
  }
587
601
  })();