@staix/agent-hub 0.12.5 → 0.12.7
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 +21 -0
- package/docs/cooperbench.md +5 -3
- package/docs/operations.md +13 -8
- package/docs/specs/2026-09-19-agent-hub-design.md +1 -1
- package/docs/verification/2026-10-03-0.12.6.md +171 -0
- package/docs/verification/2026-10-03-0.12.7.md +58 -0
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +632 -956
- package/src/adapters/acp.ts +7 -5
- package/src/adapters/codex-appserver.ts +8 -6
- package/src/adapters/pi.ts +9 -4
- package/src/hub/child-process.ts +26 -8
- package/src/hub/daemon.ts +7 -1
- package/src/hub/project.ts +5 -4
- package/src/hub/tasks.ts +23 -4
package/src/adapters/acp.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
|
2
2
|
import { createInterface } from "node:readline";
|
|
3
3
|
import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
|
|
4
4
|
import { BasePeer } from "../hub/peers.ts";
|
|
5
|
-
import { childEnv, stopOwnedProcess } from "../hub/child-process.ts";
|
|
5
|
+
import { childEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
6
6
|
|
|
7
7
|
export interface PermissionOption {
|
|
8
8
|
optionId: string;
|
|
@@ -85,8 +85,10 @@ export class AcpPeer extends BasePeer {
|
|
|
85
85
|
|
|
86
86
|
async start(): Promise<void> {
|
|
87
87
|
const [bin, ...args] = this.opts.cmd;
|
|
88
|
-
|
|
88
|
+
// Its own process group, stopped as a whole (#115, as Codex's in #113): an agent CLI may be a launcher with a native child.
|
|
89
|
+
const proc = spawn(bin!, args, { cwd: this.opts.cwd, env: childEnv({ ...process.env, ...(this.opts.env ?? {}) }), stdio: ["pipe", "pipe", "pipe"], detached: true });
|
|
89
90
|
this.proc = proc;
|
|
91
|
+
trackGroup(proc);
|
|
90
92
|
proc.on("error", (e) => this.down(`spawn failed: ${e.message}`));
|
|
91
93
|
proc.on("exit", (code) => this.down(`exited with code ${code}`));
|
|
92
94
|
proc.stdin.on("error", () => {}); // EPIPE from a child that died; `exit` / `error` already report it
|
|
@@ -112,7 +114,7 @@ export class AcpPeer extends BasePeer {
|
|
|
112
114
|
try {
|
|
113
115
|
this.sessionId = (await Promise.race([handshake(), timeout])).sessionId;
|
|
114
116
|
} catch (e) {
|
|
115
|
-
await stopOwnedProcess(proc);
|
|
117
|
+
await stopOwnedProcess(proc, { group: true }).catch((stop: Error) => this.opts.log?.(`[${this.id}] ${stop.message}`));
|
|
116
118
|
throw e;
|
|
117
119
|
}
|
|
118
120
|
this.setState("idle");
|
|
@@ -122,8 +124,8 @@ export class AcpPeer extends BasePeer {
|
|
|
122
124
|
if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: "ACP session stopped before settlement" });
|
|
123
125
|
this.activeDeliveryId = undefined;
|
|
124
126
|
const proc = this.proc;
|
|
125
|
-
if (!proc
|
|
126
|
-
await stopOwnedProcess(proc);
|
|
127
|
+
if (!proc) return;
|
|
128
|
+
await stopOwnedProcess(proc, { group: true }); // also when it exited: what it left in its group fails the stop
|
|
127
129
|
if (this.proc === proc) this.proc = undefined;
|
|
128
130
|
}
|
|
129
131
|
|
|
@@ -139,14 +139,17 @@ export class CodexPeer extends BasePeer {
|
|
|
139
139
|
});
|
|
140
140
|
} catch (error) {
|
|
141
141
|
const proc = this.proc;
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
if (this.proc === proc) this.proc = undefined;
|
|
145
|
-
}
|
|
142
|
+
// The start's own error is the one reported: a stop that fails too is logged beside it.
|
|
143
|
+
if (proc && (await this.stopQuietly(proc)) && this.proc === proc) this.proc = undefined;
|
|
146
144
|
throw error;
|
|
147
145
|
}
|
|
148
146
|
}
|
|
149
147
|
|
|
148
|
+
/** Stops `proc` as a group; a failure is logged and reported as false, for paths that throw an error of their own. */
|
|
149
|
+
private stopQuietly(proc: ChildProcess): Promise<boolean> {
|
|
150
|
+
return stopOwnedProcess(proc, { group: true }).then(() => true, (error: Error) => { this.opts.log?.(`[${this.id}] ${error.message}`); return false; });
|
|
151
|
+
}
|
|
152
|
+
|
|
150
153
|
async stop(): Promise<void> {
|
|
151
154
|
this.server?.stop(true);
|
|
152
155
|
this.claimedTui?.tui.close(1001, "hub shutting down");
|
|
@@ -318,8 +321,7 @@ export class CodexPeer extends BasePeer {
|
|
|
318
321
|
}
|
|
319
322
|
if (gone) throw new Error(gone);
|
|
320
323
|
const proc = this.proc;
|
|
321
|
-
await
|
|
322
|
-
if (this.proc === proc) this.proc = undefined;
|
|
324
|
+
if (proc && (await this.stopQuietly(proc)) && this.proc === proc) this.proc = undefined;
|
|
323
325
|
throw new Error("codex app-server did not become healthy within 10 s");
|
|
324
326
|
}
|
|
325
327
|
|
package/src/adapters/pi.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { join, resolve, relative } from "node:path";
|
|
|
4
4
|
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
5
5
|
import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
|
|
6
6
|
import { BasePeer } from "../hub/peers.ts";
|
|
7
|
-
import { stopOwnedProcess } from "../hub/child-process.ts";
|
|
7
|
+
import { stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
8
8
|
import { realPath } from "../hub/project.ts";
|
|
9
9
|
import type { ExecutionBudgetDecision, ExecutionUnit } from "../hub/execution-budget.ts";
|
|
10
10
|
|
|
@@ -266,7 +266,9 @@ export class PiPeer extends BasePeer {
|
|
|
266
266
|
const command = this.opts.cmd ?? ["pi"];
|
|
267
267
|
this._tuiLaunch = { cmd: command[0]!, args: [...command.slice(1), ...args], env };
|
|
268
268
|
if (this.opts.mode === "tui") { return; }
|
|
269
|
-
|
|
269
|
+
// Its own process group, stopped as a whole (#115, as Codex's in #113).
|
|
270
|
+
this.proc = spawn(command[0]!, [...command.slice(1), ...args], { cwd: this.opts.cwd, env, stdio: ["pipe", "pipe", "pipe"], detached: true });
|
|
271
|
+
trackGroup(this.proc);
|
|
270
272
|
this.proc.stdout.on("data", (chunk) => this.onOutput(String(chunk)));
|
|
271
273
|
this.proc.stderr.on("data", (chunk) => this.opts.log?.(`[${this.id}] ${String(chunk).trimEnd()}`));
|
|
272
274
|
this.proc.on("error", (error) => this.fail(error));
|
|
@@ -311,8 +313,10 @@ export class PiPeer extends BasePeer {
|
|
|
311
313
|
this.resolveTuiExit?.(); this.resolveTuiExit = undefined;
|
|
312
314
|
}
|
|
313
315
|
const proc = this.proc;
|
|
314
|
-
|
|
315
|
-
|
|
316
|
+
// Also when it exited: what it left in its group fails the stop. The rest of the teardown runs either way; a process
|
|
317
|
+
// that could not be stopped stays recorded, so a later stop tries it again.
|
|
318
|
+
let failed: unknown;
|
|
319
|
+
if (proc) await stopOwnedProcess(proc, { group: true }).then(() => { if (this.proc === proc) this.proc = undefined; }, (error: unknown) => { failed = error; });
|
|
316
320
|
for (const waiter of this.tuiWaiters) { clearTimeout(waiter.timer); waiter.resolve(undefined); }
|
|
317
321
|
this.tuiWaiters = [];
|
|
318
322
|
for (const pending of this.tuiCommands.values()) pending.reject(new Error("Pi owner stopped"));
|
|
@@ -320,6 +324,7 @@ export class PiPeer extends BasePeer {
|
|
|
320
324
|
for (const pending of this.pending.values()) pending.reject(new Error("Pi owner stopped"));
|
|
321
325
|
this.pending.clear();
|
|
322
326
|
this.server?.stop(true); this.server = undefined; this.setState("offline");
|
|
327
|
+
if (failed) throw failed;
|
|
323
328
|
}
|
|
324
329
|
|
|
325
330
|
/**
|
package/src/hub/child-process.ts
CHANGED
|
@@ -102,18 +102,31 @@ export async function stopOwnedProcess(proc: ChildProcess, { termMs = 1_000, kil
|
|
|
102
102
|
// ponytail: a leader that exited before the stop (its launcher killed from outside) is not swept, as its pid may be
|
|
103
103
|
// reused once its group empties; its pipes are dropped so a survivor cannot keep the hub alive, and a group that
|
|
104
104
|
// still has members fails the stop instead of reading as done. Record the leader's start time at spawn to sweep it.
|
|
105
|
-
if (
|
|
106
|
-
dropPipes(proc);
|
|
105
|
+
if (proc.pid === undefined) return;
|
|
106
|
+
dropPipes(proc); // whatever it left cannot keep the hub alive through them
|
|
107
|
+
if (!group) return;
|
|
107
108
|
if (emptied.has(proc)) return; // its group was seen gone since: the id may be someone else's now
|
|
108
109
|
// A pid is not given out while a group with that id exists: a live process with the leader's pid means the group was
|
|
109
110
|
// emptied and the id is someone else's now. Members without it are what the leader left (zombies are not listed).
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
111
|
+
// Members still finishing their own exit get up to `killMs` before the stop fails.
|
|
112
|
+
for (const end = Date.now() + killMs; ; await Bun.sleep(100)) {
|
|
113
|
+
const rows = await table();
|
|
114
|
+
const reused = rows?.some((r) => r.pid === proc.pid);
|
|
115
|
+
const members = reused ? [] : rows?.filter((r) => r.pgid === proc.pid);
|
|
116
|
+
if (members ? !members.length : groupGone(proc.pid)) return;
|
|
117
|
+
if (Date.now() >= end) throw new Error(`owned child ${proc.pid} exited before the stop and its process group still has members${members ? ` (${members.map((r) => r.pid).join(", ")})` : ""}: not signalled; stop them to restart it`);
|
|
118
|
+
}
|
|
114
119
|
}
|
|
115
120
|
if (group) return stopGroup(proc, proc.pid, termMs, killMs, table);
|
|
121
|
+
try {
|
|
122
|
+
await stopAlone(proc, termMs, killMs);
|
|
123
|
+
} finally {
|
|
124
|
+
dropPipes(proc); // a process it started and left may still hold them: it must not keep the hub alive
|
|
125
|
+
}
|
|
126
|
+
}
|
|
116
127
|
|
|
128
|
+
/** SIGTERM, then SIGKILL, to the child alone, each confirmed by its exit. */
|
|
129
|
+
async function stopAlone(proc: ChildProcess, termMs: number, killMs: number): Promise<void> {
|
|
117
130
|
const waitForExit = (timeoutMs: number): Promise<boolean> =>
|
|
118
131
|
new Promise((resolve) => {
|
|
119
132
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -235,11 +248,16 @@ async function stopGroup(proc: ChildProcess, pid: number, termMs: number, killMs
|
|
|
235
248
|
term(first);
|
|
236
249
|
// The grace period, for the leader and for what it started: what they start meanwhile is recorded while its parent
|
|
237
250
|
// still runs.
|
|
251
|
+
const leaderGone = new Promise<void>((resolve) => (exited() ? resolve() : proc.once("exit", () => resolve())));
|
|
238
252
|
for (const end = Date.now() + termMs; Date.now() < end; ) {
|
|
239
|
-
|
|
253
|
+
// The leader's exit ends a wait at once (a stop is usually that quick); after it, the table is read every 100 ms.
|
|
254
|
+
await (exited() ? Bun.sleep(100) : Promise.race([leaderGone, Bun.sleep(100)]));
|
|
240
255
|
const rows = await look();
|
|
241
256
|
term(rows);
|
|
242
|
-
if (exited() && rows && !left(rows).length)
|
|
257
|
+
if (exited() && rows && !left(rows).length) {
|
|
258
|
+
if (!unproven(rows).length) return; // done: this read shows none of it
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
243
261
|
}
|
|
244
262
|
// Freeze, enumerate, kill: a stopped process starts nothing, so a read after the freeze sees all of it.
|
|
245
263
|
for (const end = Date.now() + killMs; ; ) {
|
package/src/hub/daemon.ts
CHANGED
|
@@ -1023,6 +1023,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1023
1023
|
* opened without blocking and only as a regular file, and the last version found is kept for a tail of big rows.
|
|
1024
1024
|
*/
|
|
1025
1025
|
const claudeVersions = new Map<string, string>();
|
|
1026
|
+
const claudeStamps = new Map<string, string>(); // the transcript's size and mtime when its version was read (#115)
|
|
1026
1027
|
const claudeVersion = (): string | undefined => {
|
|
1027
1028
|
const path = claudeSession().transcriptPath;
|
|
1028
1029
|
if (!path) return undefined;
|
|
@@ -1031,8 +1032,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1031
1032
|
fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK);
|
|
1032
1033
|
const st = fstatSync(fd);
|
|
1033
1034
|
if (!st.isFile()) return claudeVersions.get(path);
|
|
1035
|
+
// Asked several times per assignment: the last MiB is read again only once the transcript changed.
|
|
1036
|
+
const stamp = `${st.size}:${st.mtimeMs}`;
|
|
1037
|
+
if (claudeStamps.get(path) === stamp) return claudeVersions.get(path); // no version in it is an answer too
|
|
1034
1038
|
const buf = Buffer.alloc(Math.min(st.size, 1024 * 1024));
|
|
1035
1039
|
readSync(fd, buf, 0, buf.length, st.size - buf.length);
|
|
1040
|
+
claudeStamps.set(path, stamp); // after the read: one that failed is tried again
|
|
1036
1041
|
for (const line of buf.toString("utf8").split("\n").reverse()) {
|
|
1037
1042
|
try {
|
|
1038
1043
|
const version = JSON.parse(line)?.version;
|
|
@@ -1697,7 +1702,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1697
1702
|
recoveryTaskPreface("pi");
|
|
1698
1703
|
await ensurePreface("pi");
|
|
1699
1704
|
bus.add(pi);
|
|
1700
|
-
|
|
1705
|
+
// The start's own error is the one reported; a stop that fails too is logged beside it (#115).
|
|
1706
|
+
try { await pi.start(); } catch (error) { await pi.stop().catch((stop: Error) => log(`pi stop after a failed start: ${stop.message}`)); throw error; }
|
|
1701
1707
|
return { ok: true, ...(mode === "tui" ? { launch: pi.tuiLaunch } : {}) };
|
|
1702
1708
|
}
|
|
1703
1709
|
if (peer === "local") {
|
package/src/hub/project.ts
CHANGED
|
@@ -18,10 +18,11 @@ export function canonicalPath(path: string): string {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* realpathSync, except that Bun 1.3.14 throws ENOENT for an existing path containing a
|
|
22
|
-
* path goes to the system realpath, which handles it and returns every component as
|
|
23
|
-
* names, and on a case-insensitive disk `.GIT/config` or `id_rſa` would otherwise
|
|
24
|
-
* path or a dangling symlink still throws, which guardPath relies on. Other paths take
|
|
21
|
+
* realpathSync, except that Bun (1.3.14, and 1.4.2 still: #121) throws ENOENT for an existing path containing a
|
|
22
|
+
* backslash (issue #26). Such a path goes to the system realpath, which handles it and returns every component as
|
|
23
|
+
* stored on disk: guardPath checks names, and on a case-insensitive disk `.GIT/config` or `id_rſa` would otherwise
|
|
24
|
+
* pass for another file. A missing path or a dangling symlink still throws, which guardPath relies on. Other paths take
|
|
25
|
+
* realpathSync's answer unchanged.
|
|
25
26
|
*/
|
|
26
27
|
export function realPath(path: string): string {
|
|
27
28
|
try {
|
package/src/hub/tasks.ts
CHANGED
|
@@ -97,6 +97,8 @@ const OWNERSHIP_EVENTS = new Set(["assigned", "escalated", "reassigned", "unassi
|
|
|
97
97
|
const WITH_DONE = "with its done";
|
|
98
98
|
/** How many times a task changed hands: a cohort member's generation (issue #107). */
|
|
99
99
|
const ownerGen = (t: Task) => t.history.filter((h) => OWNERSHIP_EVENTS.has(h.event)).length;
|
|
100
|
+
/** One hand-over of a task to an owner: a task handed back to a peer that had it before is a new one (#115). */
|
|
101
|
+
const handOver = (t: Task, owner: PeerId) => `${t.id}@${owner}#${ownerGen(t)}`;
|
|
100
102
|
/** When the task was handed to its current owner (its creation, if it never changed hands). */
|
|
101
103
|
const handedAt = (t: Task) => [...t.history].reverse().find((h) => OWNERSHIP_EVENTS.has(h.event) && h.event !== "unassigned")?.at ?? t.history[0]?.at ?? Date.now();
|
|
102
104
|
|
|
@@ -141,6 +143,9 @@ export class Tasks {
|
|
|
141
143
|
// What the on-prem worker says about a PII task is private on the bus (console tail and log show a stub), so the
|
|
142
144
|
// board keeps the text: `ahub task show <id>` is where the console user reads it, a refusal included.
|
|
143
145
|
d.bus.tap((e) => {
|
|
146
|
+
// A turn that ends takes its hand-overs with it: busy later is another turn (#115). The adapter's own state, since a
|
|
147
|
+
// pause shows as `paused` while the turn goes on.
|
|
148
|
+
if (e.t === "state" && d.bus.peers.get(e.peer)?.state !== "busy") for (const [k, p] of this.sent) if (p === e.peer) this.sent.delete(k);
|
|
144
149
|
if (e.t !== "envelope" || !e.env.private || e.env.from === HUB || !e.env.refs?.task) return;
|
|
145
150
|
try {
|
|
146
151
|
d.board.update(Number(e.env.refs.task), e.env.from, "answer", {}, e.env.body.slice(0, 4000));
|
|
@@ -308,15 +313,18 @@ export class Tasks {
|
|
|
308
313
|
const open = this.d.board.list().filter((t) => OPEN.includes(t.state) && t.id !== task.id && (t.id !== other.id || t.state !== "proposed"));
|
|
309
314
|
// One task of a class is one unit: the only normalization the board supports, so another class is unknown.
|
|
310
315
|
const unit = task.class === other.class ? 1 : undefined;
|
|
316
|
+
const taking = (p: PeerId) => states[p] === "busy" && (p === candidate ? this.sent.has(handOver(task, p)) : other.state === "proposed" && this.sent.has(handOver(other, p)));
|
|
311
317
|
return predictSplit({
|
|
312
318
|
peers,
|
|
313
319
|
observations: Object.fromEntries(peers.map((p) => [p, this.splitObservations(task.class, p, task.id)])),
|
|
314
320
|
units: [unit, unit],
|
|
315
321
|
profiles: Object.fromEntries(peers.map((p) => [p, this.d.splitProfile?.(p)])),
|
|
316
322
|
backlog: Object.fromEntries(peers.map((p) => [p, open.filter((t) => t.owner === p).length])),
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
|
|
323
|
+
// Busy is taking the task in question only in the turn that task started (an owner goes busy as its task is
|
|
324
|
+
// delivered) or in which it claimed it: the routed peer this very task, the other owner the overlapped one while it
|
|
325
|
+
// is not started. Busy otherwise, it is at work on something else (#109, #115; a routing or cohort record is taken
|
|
326
|
+
// before the task is sent, so a busy candidate is not available then).
|
|
327
|
+
available: Object.fromEntries(peers.map((p) => [p, !failing[p] && (states[p] === "idle" || taking(p))])),
|
|
320
328
|
});
|
|
321
329
|
}
|
|
322
330
|
|
|
@@ -650,6 +658,9 @@ export class Tasks {
|
|
|
650
658
|
if (shadow) this.d.recordSplit?.(next.id, shadow, "routing");
|
|
651
659
|
} catch { /* shadow only: never between the board write and the delivery */ }
|
|
652
660
|
}
|
|
661
|
+
// A claim is its own hand-over: the claimant took the task in the turn it is in, so it is taking it, not busy elsewhere.
|
|
662
|
+
// The cohort record formed just below reads it, and so does `route explain` while that turn lasts.
|
|
663
|
+
if (opts.claim && a.owner === by && this.d.bus.peers.get(by)?.state === "busy") this.sent.set(handOver(next, by), by);
|
|
653
664
|
const hits = this.overlapHits(next);
|
|
654
665
|
this.formCohort(next, hits);
|
|
655
666
|
if (hits.length) {
|
|
@@ -684,7 +695,14 @@ export class Tasks {
|
|
|
684
695
|
context && !pii ? `Handoff from the previous owner:\n${this.screen(task, context, "handoff", a.owner).slice(0, 3000)}` : "",
|
|
685
696
|
`Take it with hub_task_accept {id: ${task.id}, plan: {paths, symbols, signatures, insertion_points}} (what you will change, before you start${pii ? "" : "; owners of overlapping tasks see it"}) or pass with hub_task_decline. When finished: hub_task_done {id: ${task.id}, summary: what changed, why, and the check you ran with its result, refs}.`,
|
|
686
697
|
].filter(Boolean).join("\n\n");
|
|
687
|
-
|
|
698
|
+
// A hand-over the owner takes in a turn of its own (#109, #115): idle before, busy after and not queued, this envelope
|
|
699
|
+
// was in the delivery that started its turn. Busy before, it is queued, or steered into a turn about something else
|
|
700
|
+
// (Codex, Pi); held, it starts no turn yet: neither is taking it.
|
|
701
|
+
const owner = task.owner!;
|
|
702
|
+
const idle = this.d.bus.stateOf(owner) === "idle";
|
|
703
|
+
const env = newEnvelope(HUB, body, { to: [owner], kind: "task", priority: "important", refs: { ...task.refs, task: String(task.id) }, ...(pii ? { private: true } : {}) });
|
|
704
|
+
this.d.bus.publish(env);
|
|
705
|
+
if (idle && this.d.bus.stateOf(owner) === "busy" && !this.d.bus.queueIds(owner).includes(env.id)) this.sent.set(handOver(task, owner), owner);
|
|
688
706
|
}
|
|
689
707
|
|
|
690
708
|
/** Nobody, the console user included, works on a task before what it waits for is approved. */
|
|
@@ -702,6 +720,7 @@ export class Tasks {
|
|
|
702
720
|
}
|
|
703
721
|
|
|
704
722
|
private readonly offered = new Set<number>(); // ready tasks offered in this hub run
|
|
723
|
+
private readonly sent = new Map<string, PeerId>(); // hand-over (`handOver`) -> owner, for the owner's current turn: its task envelope started it, or it claimed the task in it
|
|
705
724
|
|
|
706
725
|
/**
|
|
707
726
|
* A stop between an approval and the assignment of its dependents (both are saved on their own) leaves them ownerless
|