@staix/agent-hub 0.7.6 → 0.7.8

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.
@@ -26,7 +26,7 @@ export const TASK_TOOLS: HubTool[] = [
26
26
  tool("hub_task_propose", "Put a piece of work on the shared task board. The hub assigns an owner by class (routing.toml) unless you name one. Classes: plan, implement, bulk_edit, test, review, summarize, triage. Name the class when you know it; without one the hub tries to pick it. Name yourself as owner to claim work nobody assigned you: it starts in progress. Put the paths the work touches in refs; the hub says when they overlap another open task.", { title: str, class: { type: "string", enum: ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] }, detail: str, refs, owner: { type: "string", description: "peer id; yours to claim the work, omit to let the hub route it" } }, ["title"]),
27
27
  tool("hub_task_accept", "Take a task that was assigned to you.", { id }, ["id"]),
28
28
  tool("hub_task_decline", "Pass on a task assigned to you; the hub offers it to the next peer.", { id, reason: str }, ["id"]),
29
- tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs }, ["id", "summary"]),
29
+ tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs }, ["id", "summary"]),
30
30
  tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested"] } }),
31
31
  tool("hub_review", "Give your verdict on a task you were asked to review. Two changes_requested in a row move the task to another peer.", { id, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str }, ["id", "verdict"]),
32
32
  tool("hub_checkpoint", "Answer a checkpoint request from the hub (your quota window is nearly used up): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str }, ["summary"]),
package/src/hub/tasks.ts CHANGED
@@ -15,14 +15,22 @@ export interface TasksDeps {
15
15
  memory?: MemoryClient;
16
16
  /** a line for the human: console tail and hub.log */
17
17
  notify: (line: string) => void;
18
+ /** The completion check configured for a class, if any (local config only; issue #7). */
19
+ check?: (cls: TaskClass) => string | undefined;
20
+ /** Runs one check; the hub runs them one at a time. */
21
+ runCheck?: (command: string) => Promise<{ code: number | null; timedOut: boolean; interrupted?: boolean; tail: string }>;
18
22
  /** Hands a saved note to every other peer; it rides on their next delivery. */
19
23
  share?: (by: PeerId, line: string) => void;
24
+ /** Hands one peer a line that rides on its next delivery, without a turn of its own (issue #6). */
25
+ tell?: (peer: PeerId, line: string) => void;
20
26
  /** Optional: name a class for a task proposed without one. `onCampus` says whether the model call stays on campus. */
21
27
  triage?: { classify: (title: string, detail: string) => Promise<TaskClass | undefined>; onCampus: () => Promise<boolean> };
22
28
  }
23
29
 
24
30
  const ESCALATE_AFTER = 2;
25
31
  const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
32
+ /** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
33
+ const QUIET_EVENTS = new Set(["answer", "reviewer changed"]);
26
34
 
27
35
  /** Same path, or one is a directory of the other; the project root (`.`) holds everything. */
28
36
  const samePlace = (a: string, b: string) => {
@@ -90,10 +98,14 @@ export class Tasks {
90
98
  if (!pii || (await this.d.triage.onCampus().catch(() => false))) cls = await this.d.triage.classify(text.title, text.detail).catch(() => undefined);
91
99
  triaged = !!cls;
92
100
  }
101
+ // A claim is work the caller will do itself: without a class and a model to name one, it is implementation (#6).
102
+ const defaulted = !cls && input.owner === by;
103
+ if (defaulted) cls = "implement";
93
104
  if (!cls) throw new Error(`class is required (one of ${CLASSES.join(", ")}); the hub could not name one for you`);
94
105
  const draft = { ...text, class: cls };
95
106
  let task = this.d.board.propose(by, { ...draft, signals });
96
107
  if (triaged) task = this.d.board.update(task.id, "hub", "triaged", {}, `class ${cls} named by the hub's model`);
108
+ if (defaulted) task = this.d.board.update(task.id, "hub", "class defaulted", {}, "class implement for a claim without one");
97
109
  this.d.notify(`task ${this.publicTitle(task)} proposed by ${by} [${task.class}]${task.signals.length ? ` signals: ${task.signals.join(", ")}` : ""}`);
98
110
  // Naming yourself is a claim: the work is already yours, so no offer comes back to you (paper: Agensh CLAIM, #68).
99
111
  return this.assignOwner(task, by, input.owner ? { candidates: [input.owner], claim: input.owner === by } : {});
@@ -105,18 +117,65 @@ export class Tasks {
105
117
  * sides: their refs are hidden from cloud peers.
106
118
  */
107
119
  overlaps(task: Task, forOwner = true): string {
108
- const mine = task.refs.paths ?? [];
109
- if (!mine.length || this.isPii(task)) return "";
110
- const hits = this.d.board.list().flatMap((t) => {
111
- if (t.id === task.id || !t.owner || t.owner === task.owner || !OPEN.includes(t.state) || this.isPii(t)) return [];
112
- const shared = mine.filter((p) => (t.refs.paths ?? []).some((q) => samePlace(p, q)));
113
- return shared.length ? [`#${t.id} (owner ${t.owner}) on ${shared.join(", ")}`] : [];
114
- });
120
+ const hits = this.overlapHits(task).map((h) => `#${h.task.id} (owner ${h.task.owner}) on ${h.paths.join(", ")}`);
115
121
  if (!hits.length) return "";
116
122
  const who = forOwner ? "Settle it with that owner via hub_send before editing those paths." : `${task.owner ?? "Whoever takes it"} is told to settle it.`;
117
123
  return `Overlaps ${hits.join("; ")}. ${who}`;
118
124
  }
119
125
 
126
+ /** While a gone owner's tasks move, its other tasks are about to move too: they are no one to settle with. */
127
+ private releasing: PeerId | undefined;
128
+
129
+ private overlapHits(task: Task): { task: Task; paths: string[] }[] {
130
+ const mine = task.refs.paths ?? [];
131
+ if (!mine.length || this.isPii(task)) return [];
132
+ return this.d.board.list().flatMap((t) => {
133
+ if (t.id === task.id || !t.owner || t.owner === task.owner || t.owner === this.releasing || !OPEN.includes(t.state) || this.isPii(t)) return [];
134
+ const paths = mine.filter((p) => (t.refs.paths ?? []).some((q) => samePlace(p, q)));
135
+ return paths.length ? [{ task: t, paths }] : [];
136
+ });
137
+ }
138
+
139
+ /** The earlier owner hears of an overlap on its next delivery, costing it no turn; the newcomer settles it (#6). */
140
+ private tellEarlierOwners(task: Task): void {
141
+ if (!task.owner || !this.d.tell) return;
142
+ for (const hit of this.overlapHits(task)) {
143
+ if (hit.task.owner === USER || hit.task.owner === HUB) continue;
144
+ this.d.tell(hit.task.owner!, noteLine(HUB, "finding", `task #${task.id} (owner ${task.owner}) now overlaps your #${hit.task.id} on ${hit.paths.join(", ")}; ${task.owner} is told to settle it`));
145
+ }
146
+ }
147
+
148
+ /**
149
+ * An owner offline past the limit loses its open tasks back to routing, through the same path a budget pause uses.
150
+ * Only when some other peer can take a task: with nobody to hand it to, it stays with its owner.
151
+ */
152
+ async releaseFromGone(peer: PeerId, minutes: number): Promise<{ id: number; title: string; to: PeerId | null }[]> {
153
+ const moved: { id: number; title: string; to: PeerId | null }[] = [];
154
+ const why = `owner ${peer} offline for ${minutes} min`;
155
+ const ids = this.d.board.list().filter((t) => t.owner === peer && OPEN.includes(t.state)).map((t) => t.id);
156
+ this.releasing = peer;
157
+ try {
158
+ for (const id of ids) {
159
+ // Awaits run between tasks (briefs, memory): re-read, and stop for anything that changed meanwhile.
160
+ const task = this.d.board.get(id);
161
+ if (!task || task.owner !== peer || !OPEN.includes(task.state) || this.d.bus.stateOf(peer) !== "offline") continue;
162
+ if (!assign(task, this.states(), this.d.routing(), { exclude: [...this.declined(task), peer] }).owner) continue;
163
+ try {
164
+ const back = task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, why) : task;
165
+ const next = await this.assignOwner(back, HUB, { exclude: [peer], event: "reassigned", note: why });
166
+ moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner });
167
+ // The gone owner hears it on its next delivery, if it comes back mid-work.
168
+ if (next.owner && next.owner !== peer) this.d.tell?.(peer, noteLine(HUB, "decision", `task #${task.id} moved to ${next.owner} while you were offline; stop working on it`));
169
+ } catch (e) {
170
+ this.d.notify(`task ${this.publicTitle(task)}: could not be released from ${peer}: ${(e as Error).message}`);
171
+ }
172
+ }
173
+ } finally {
174
+ this.releasing = undefined;
175
+ }
176
+ return moved;
177
+ }
178
+
120
179
  /** Same code as assignment, without doing it. */
121
180
  explain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }): string[] {
122
181
  const routing = this.d.routing();
@@ -140,7 +199,10 @@ export class Tasks {
140
199
  }
141
200
  const next = this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.event === "escalated" ? { rejections: 0 } : {}) }, opts.note ?? `to ${a.owner}`);
142
201
  const overlap = this.overlaps(next, false);
143
- if (overlap) this.d.notify(`task ${this.publicTitle(next)} (${next.owner}): ${overlap}`);
202
+ if (overlap) {
203
+ this.d.notify(`task ${this.publicTitle(next)} (${next.owner}): ${overlap}`);
204
+ this.tellEarlierOwners(next);
205
+ }
144
206
  if (opts.claim && a.owner === by) {
145
207
  const claimed = this.d.board.update(next.id, by, "accepted", { state: "in_progress" });
146
208
  this.d.notify(`task ${this.publicTitle(claimed)} claimed by ${by}`);
@@ -197,13 +259,64 @@ export class Tasks {
197
259
  return this.assignOwner(back, HUB, { event: "reassigned", clearOnFail: true });
198
260
  }
199
261
 
262
+ /** Tasks whose check is queued or running, and the owner it was started for. */
263
+ private readonly checking = new Map<number, PeerId | null>();
264
+ private checkQueue: Promise<void> = Promise.resolve();
265
+
266
+ /** Whether a completion check is still running for this task. */
267
+ isChecking = (id: number) => this.checking.has(id);
268
+
200
269
  async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs): Promise<Task> {
201
270
  let task = this.need(id);
202
271
  this.mine(task, by, "owner");
203
272
  if (task.state === "in_review" || task.state === "approved") throw new Error(`task #${task.id} is already ${task.state}`);
273
+ if (this.checking.has(task.id)) {
274
+ // The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
275
+ throw new Error(this.checking.get(task.id) === task.owner ? `task #${task.id}: its check is still running; its result comes as a task message` : `task #${task.id}: a check from before it changed hands is still running; call hub_task_done again in a few minutes`);
276
+ }
204
277
  if (task.state === "proposed" || task.state === "changes_requested") task = this.d.board.update(task.id, by, "accepted", { state: "in_progress" }); // done without a separate accept
278
+ const command = this.d.runCheck ? this.d.check?.(task.class) : undefined;
279
+ if (!command) return this.complete(task, by, summary, refs);
280
+ // The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
281
+ this.checking.set(task.id, task.owner);
282
+ const pending = this.d.board.update(task.id, by, "done (checking)", { refs: cleanRefs(refs) }, summary);
283
+ this.d.notify(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
284
+ const seen = { events: pending.history.length, owner: pending.owner };
285
+ this.checkQueue = this.checkQueue
286
+ .then(() => this.finishChecked(pending.id, by, summary, command, seen))
287
+ .catch((e: Error) => this.d.notify(`task #${pending.id}: its check result could not be recorded: ${e.message}`));
288
+ return pending;
289
+ }
290
+
291
+ private async finishChecked(id: number, by: PeerId, summary: string | undefined, command: string, seen: { events: number; owner: PeerId | null }): Promise<void> {
292
+ const result = await this.d.runCheck!(command).catch((e: Error) => ({ code: null, timedOut: false, interrupted: false, tail: e.message }));
293
+ this.checking.delete(id);
294
+ const task = this.d.board.get(id);
295
+ if (!task) return;
296
+ const outcome = `${command} -> ${result.interrupted ? "interrupted by a hub stop" : result.timedOut ? "timed out" : `exit ${result.code ?? "?"}`}`;
297
+ // Not a verdict on the work, and nobody is listening any more: the owner marks the task done again (spec).
298
+ if (result.interrupted) return void this.d.board.update(id, HUB, "check interrupted", {}, outcome);
299
+ const pii = this.isPii(task);
300
+ // Moved on meanwhile (escalated, reassigned, released, and maybe back again): the result is kept, nothing else changes.
301
+ if (task.state !== "in_progress" || task.history.slice(seen.events).some((h) => !QUIET_EVENTS.has(h.event))) {
302
+ this.d.board.update(id, HUB, "check finished late", {}, outcome);
303
+ if (task.state === "in_progress" && task.owner === seen.owner) this.tell(task, `Task #${id}: the check of your earlier hub_task_done finished after the task changed hands (${outcome}). Call hub_task_done again when it is ready.`, pii);
304
+ return;
305
+ }
306
+ if (result.code === 0 && !result.timedOut) {
307
+ this.d.board.update(id, HUB, "check passed", {}, `${outcome}\n${result.tail}`.trim());
308
+ // The output goes to the reviewer with the done note, not into shared memory: nobody screened it.
309
+ await this.complete(this.d.board.get(id)!, by, `${summary ?? ""}\nCheck: ${outcome}`.trim(), undefined, result.tail);
310
+ return;
311
+ }
312
+ this.d.board.update(id, HUB, "check failed", {}, `${outcome}\n${result.tail}`.trim());
313
+ this.d.notify(`task ${this.publicTitle(task)}: its check failed (${outcome}); it stays with ${task.owner ?? by}`);
314
+ this.tell(task, `Task #${id}: its check failed.\n$ ${outcome}${result.tail ? `\n${result.tail}` : ""}\nFix it and call hub_task_done again.`, pii);
315
+ }
316
+
317
+ private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = ""): Promise<Task> {
205
318
  const reviewer = task.reviewer;
206
- const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", refs: cleanRefs(refs) }, summary);
319
+ const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", refs: cleanRefs(refs) }, checkOutput ? `${summary ?? ""}\n${checkOutput}`.trim() : summary);
207
320
  this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`);
208
321
  if (!reviewer) this.d.notify(`task ${this.publicTitle(next)} done by ${by}, no reviewer: approved`);
209
322
  else if (reviewer === USER) this.d.notify(`task ${this.publicTitle(next)} done by ${by}: review it with ahub task show ${next.id}, then ahub review ${next.id} approved|changes_requested [note]`);
@@ -23,5 +23,8 @@
23
23
  "cf_client_id_file": "",
24
24
  "cf_client_secret_file": ""
25
25
  },
26
- "local": { "deny": [], "bash_network": false, "max_steps": 30, "read_allow": [] }
26
+ "local": { "deny": [], "bash_network": false, "max_steps": 30, "read_allow": [] },
27
+ "approvals": { "timeout_s": 120 },
28
+ "tasks": { "release_after_min": 30 },
29
+ "checks": { "timeout_s": 600 }
27
30
  }