@staix/agent-hub 0.18.0 → 0.20.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 +30 -0
- package/README.md +4 -4
- package/docs/agent-notes/README.md +6 -0
- package/docs/agent-notes/adapters.md +13 -10
- package/docs/agent-notes/bus.md +1 -1
- package/docs/agent-notes/daemon.md +11 -4
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +6 -1
- package/docs/agent-notes/tests.md +8 -4
- package/docs/dogfood.md +13 -4
- package/docs/memory.md +1 -1
- package/docs/operations.md +98 -28
- package/docs/quickstart.md +13 -5
- package/docs/sdlc.md +12 -0
- package/docs/security.md +24 -15
- package/docs/smoke.md +3 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +101 -8
- package/docs/verification/2026-10-25-remote-docker-transport.md +10 -0
- package/docs/verification/2026-10-26-agy-1.4.7.md +69 -0
- package/docs/verification/2026-10-26-claude-implementer-2.1.310.md +69 -0
- package/docs/verification/2026-10-26-headless-claude-shell-context.md +62 -0
- package/docs/verification/2026-10-26-jcode-0.99.1.md +49 -0
- package/docs/verification/2026-10-26-kimi-232-reviewer-compat.md +53 -0
- package/docs/verification/2026-10-26-pi-140-compat.md +41 -0
- package/docs/verification/2026-10-26-qwen-0261-acp.md +60 -0
- package/docs/verification/2026-10-27-0.19.0.md +86 -0
- package/docs/verification/2026-10-27-0.20.0.md +75 -0
- package/docs/verification/claude-permission-transport-native.py +187 -0
- package/docs/verification/claude-shell-wrapper-host.ts +20 -0
- package/docs/verification/claude-shell-wrapper-native.py +145 -0
- package/docs/verification/pi-140-model-free.ts +57 -0
- package/docs/verification/pi-140-native.mjs +21 -0
- package/docs/verified.json +25 -17
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +373 -72
- package/src/adapters/acp.ts +8 -0
- package/src/adapters/agy/entry.ts +1 -1
- package/src/adapters/channel-peer.ts +7 -5
- package/src/adapters/claude-channel.ts +7 -3
- package/src/adapters/claude-implementer.ts +6 -3
- package/src/adapters/codex-appserver.ts +11 -1
- package/src/adapters/compat.ts +32 -18
- package/src/adapters/conformance/enrollment.ts +4 -4
- package/src/adapters/conformance/factories.ts +2 -1
- package/src/adapters/conformance/fakes/pi-rpc.ts +1 -0
- package/src/adapters/conformance/kit.ts +10 -0
- package/src/adapters/definitions/builtin-metadata.ts +2 -2
- package/src/adapters/definitions/native-factories.ts +7 -2
- package/src/adapters/definitions/reviewer-variants.ts +2 -2
- package/src/adapters/local-worker.ts +2 -0
- package/src/adapters/pi.ts +46 -3
- package/src/adapters/qwen-dual.ts +25 -0
- package/src/adapters/registry-types.ts +6 -2
- package/src/adapters/registry.ts +1 -0
- package/src/adapters/reviewer-print.ts +1 -0
- package/src/cli/agents.ts +69 -7
- package/src/cli/claude-shell-wrapper.ts +54 -0
- package/src/cli/main.ts +30 -24
- package/src/cli/output.ts +4 -0
- package/src/hub/agent-drift.ts +61 -0
- package/src/hub/board.ts +14 -2
- package/src/hub/check-runner.ts +148 -18
- package/src/hub/ci-watch.ts +140 -0
- package/src/hub/claude-launch-proof.ts +67 -2
- package/src/hub/claude-shell-wrapper.ts +349 -0
- package/src/hub/config-apply.ts +2 -2
- package/src/hub/config-edit.ts +2 -2
- package/src/hub/config-tier.ts +2 -2
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/daemon.ts +282 -113
- package/src/hub/host-executable.ts +20 -1
- package/src/hub/instances.ts +18 -2
- package/src/hub/lifecycle.ts +1 -1
- package/src/hub/operational-authority.ts +7 -0
- package/src/hub/operational-view.ts +6 -10
- package/src/hub/peers.ts +34 -0
- package/src/hub/read-only-shell-proof.ts +7 -6
- package/src/hub/review-scope.ts +13 -0
- package/src/hub/sdlc-metrics.ts +6 -4
- package/src/hub/settings.ts +1 -0
- package/src/hub/task-shell-command.ts +85 -0
- package/src/hub/task-shell-default.ts +79 -0
- package/src/hub/task-shell-proof.ts +171 -0
- package/src/hub/task-shell-sandbox.ts +61 -0
- package/src/hub/tasks.ts +274 -16
- package/src/local/deny.ts +112 -11
- package/src/local/sandbox.ts +70 -29
- package/src/local/shell-environment.ts +10 -0
- package/src/ui/index.html +1 -0
package/src/hub/tasks.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { canonicalMemorySymbolNode, normalizeMemorySymbolInput, MEMORY_SYMBOL_LIMIT, type MemorySymbolNodes } from "./memory-symbols.ts";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { taskShellBindingValid, type TaskShellBinding } from "./task-shell-command.ts";
|
|
2
4
|
import { buildTaskCodeSymbols, publicTaskCodeSymbols, taskCodeSymbolsDisjoint, HOST_CODE_SYMBOLS } from "./task-code-symbols.ts";
|
|
3
5
|
import { normalizePlanSymbol, symbolsOverlap } from "./symbol-normalizer.ts";
|
|
4
6
|
import { publicTestIntegrity, readTestIntegrity, testIntegrityBlockers, type TestIntegrity } from "./test-integrity.ts";
|
|
@@ -16,6 +18,7 @@ import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS, actualTaskDates, planningPatch, type P
|
|
|
16
18
|
import { readMergeQueue, type DeliveryTask } from "./sdlc-queue.ts";
|
|
17
19
|
import { guardedMerge, mergeModeFor, mergeView, type MergeView } from "./sdlc-merge.ts";
|
|
18
20
|
import { mirrorVerdict as mirrorToGitHub, type MirrorVerdict } from "./sdlc-mirror.ts";
|
|
21
|
+
import { CODE_SCOPE_LINE, evidenceOnlyNotice, type ReviewScope } from "./review-scope.ts";
|
|
19
22
|
import { DEFAULT_MAX_NITS, parseReviewFindings, findingsForHead, findingHead as resolveFindingHead, findingTally, type ReviewFinding, type ReviewFindingSet } from "./review-findings.ts";
|
|
20
23
|
import { readReleaseReadiness } from "./sdlc-release.ts";
|
|
21
24
|
import { loadRoleCatalog, taskRole, taskRoleText, type RoleCatalog } from "./roles.ts";
|
|
@@ -46,6 +49,7 @@ import { coverageGap, renderReviewBrief, type ReviewBrief } from "./review-brief
|
|
|
46
49
|
import { definitionOf as builtinDefinitionOf } from "../adapters/registry.ts";
|
|
47
50
|
import { ADAPTERS, DEFAULT_REVIEW_POOL, definitionOf, familyOf, type ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
48
51
|
import { plannerGrooming, nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
|
|
52
|
+
import { CiWatchers, pollCi, type CiOutcome, type CiWatch } from "./ci-watch.ts";
|
|
49
53
|
import { planningIssueReceipt, linkedReceipt, openPrFor, readPrCostHistory, readReviewReadiness, reviewReadinessFromEvidence, type PrEvidence, type ReviewReadiness, readPlanIssueMetadata, canonicalStage, checklistOf, readSdlcPr, readSpecEdit, sdlcConfig, sdlcReceipt, sdlcStatus, specState, SPEC_STATE_TEXT, stageDefinition, lifecycleStage, type SdlcConfig, type MergeRecord, type PlanIssueMetadata, sdlcGh, type SdlcGh, type SdlcStatus, type SpecEdit, type SpecState } from "./sdlc.ts";
|
|
50
54
|
|
|
51
55
|
export type TaskAssignmentRole = "owner" | "reviewer" | "planner";
|
|
@@ -95,6 +99,10 @@ export interface TasksDeps {
|
|
|
95
99
|
/** Host-verified existing symbols at this task's admitted completion head; never plan guesses. */
|
|
96
100
|
memorySymbolNodes?: (task: Task) => MemorySymbolNodes | undefined;
|
|
97
101
|
sdlc?: { config: () => SdlcConfig; gh?: SdlcGh };
|
|
102
|
+
/** #785 P1b: the per-head CI watcher's bounds; tests shorten them, the defaults are `CI_WATCH_*` in `ci-watch.ts`. */
|
|
103
|
+
ciWatch?: { deadlineMs?: number; pollMs?: number };
|
|
104
|
+
/** #785 P1c: `tasks.auto_pull`, read at each capacity release so a reload applies to the next one; absent or false pulls nothing. */
|
|
105
|
+
autoPull?: () => boolean;
|
|
98
106
|
briefs?: Briefs;
|
|
99
107
|
/** Screened live fail notes directly intersecting the task plan or refs; read-only advice. */
|
|
100
108
|
planFails?: (task: Task) => NativeItem[];
|
|
@@ -104,6 +112,8 @@ export interface TasksDeps {
|
|
|
104
112
|
privateTurn?: (peer: PeerId) => boolean;
|
|
105
113
|
/** #223 R4: the hub's reading of a verdict's context (`fresh` only for a reviewer session started for this review). */
|
|
106
114
|
reviewContext?: (by: PeerId, task: number) => "fresh" | "continuing" | undefined;
|
|
115
|
+
/** #852: what a reviewer reads. `code` (the fixed sandboxed reviewers by default, or a local `review_scope`) reads the code only; unset or `full` also runs tests and reads CI. */
|
|
116
|
+
reviewScope?: (peer: PeerId) => ReviewScope;
|
|
107
117
|
/** #223 R2: a reviewer-only instance gets review facts, not the builder's done summary. */
|
|
108
118
|
reviewOnly?: (peer: PeerId) => boolean;
|
|
109
119
|
/** #223: captured when a verdict call starts; a non-undefined result refuses the verdict at every later check. */
|
|
@@ -202,6 +212,7 @@ const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep", "memor
|
|
|
202
212
|
QUIET_EVENTS.add("ready held"); // #655: the hub's note that a ready task waits for its reserved owner's WIP slot.
|
|
203
213
|
QUIET_EVENTS.add("plan observed"); // Host observation does not move the task or replace completion intent.
|
|
204
214
|
QUIET_EVENTS.add("planner changed"); // Planning authority does not move the execution owner or revoke its handoff.
|
|
215
|
+
QUIET_EVENTS.add("shell branch bound"); // #880: the hub's host-observed branch binding (#845) is bookkeeping; it neither moves nor settles the task.
|
|
205
216
|
/** The records of the `done (checking)` entry a passed check now completes: what the owner delivered then (#509a). */
|
|
206
217
|
const latestPendingDeliverables = (task: Task): DeliverableRecord[] | undefined => {
|
|
207
218
|
const checking = task.history.findLastIndex(entry => entry.event === "done (checking)");
|
|
@@ -349,8 +360,17 @@ export class Tasks {
|
|
|
349
360
|
private sweepConfig: TaskSweepConfig;
|
|
350
361
|
private readonly noWriteTurns = new NoWriteTurns();
|
|
351
362
|
private sweeping = false;
|
|
363
|
+
/** #785 P1b: one bounded CI watcher per exact PR head, and the reviews the hub held as premature, by task id. */
|
|
364
|
+
private readonly ciWatchers: CiWatchers;
|
|
365
|
+
private readonly heldReviews = new Map<number, { head: string; reviewer: PeerId; revision: number }>();
|
|
352
366
|
|
|
353
367
|
constructor(private readonly d: TasksDeps) {
|
|
368
|
+
this.ciWatchers = new CiWatchers({
|
|
369
|
+
...d.ciWatch,
|
|
370
|
+
poll: watch => pollCi(watch, watch.head, d.sdlc?.config().requiredChecks ?? [], d.sdlc?.gh ?? sdlcGh),
|
|
371
|
+
current: watch => this.ciWatchCurrent(watch),
|
|
372
|
+
finish: (watch, outcome) => this.ciCompleted(watch, outcome),
|
|
373
|
+
});
|
|
354
374
|
d.board.trackPlanRevisions = () => (d.sdlc?.config().mode ?? "off") !== "off";
|
|
355
375
|
this.repositories = new TaskRepositories(d.cwd, d.repository);
|
|
356
376
|
d.board.proposalPlanner = task => this.proposalPlanner(task);
|
|
@@ -698,6 +718,8 @@ export class Tasks {
|
|
|
698
718
|
...(h.selfReview ? { selfReview: true } : {}),
|
|
699
719
|
...(h.reviewerFamily ? { reviewerFamily: screenHistoryText(h.reviewerFamily, "reviewer family") } : {}),
|
|
700
720
|
...(h.reviewerReason ? { reviewerReason: h.reviewerReason } : {}),
|
|
721
|
+
...(h.reviewScope === "code" ? { reviewScope: "code" as const } : {}),
|
|
722
|
+
...(h.evidenceOnly ? { evidenceOnly: true as const } : {}),
|
|
701
723
|
...(h.unmetCriteria?.length ? { unmetCriteria: h.unmetCriteria } : {}),
|
|
702
724
|
...(h.deliverables?.length ? { deliverables: publicDeliverables(h.deliverables, this.nameable) } : {}),
|
|
703
725
|
...(h.reviewBlocked ? { reviewBlocked: true } : {}),
|
|
@@ -775,7 +797,7 @@ export class Tasks {
|
|
|
775
797
|
if (h.owner === undefined || h.owner !== owner) since = i;
|
|
776
798
|
owner = h.owner;
|
|
777
799
|
});
|
|
778
|
-
return new Set(task.history.slice(since + 1).filter((h) => h.event === "changes_requested").map((h) => h.by));
|
|
800
|
+
return new Set(task.history.slice(since + 1).filter((h) => h.event === "changes_requested" && h.evidenceOnly !== true).map((h) => h.by));
|
|
779
801
|
}
|
|
780
802
|
|
|
781
803
|
/** Workload is board/queue data only: wip() never calls health() or weights(). */
|
|
@@ -872,7 +894,7 @@ export class Tasks {
|
|
|
872
894
|
if (!OWNERSHIP_EVENTS.has(given.event) || given.event === "unassigned" || given.owner !== peer || given.profile !== profile || given.by === peer) return [];
|
|
873
895
|
const next = t.history.findIndex((h, j) => j > i && OWNERSHIP_EVENTS.has(h.event));
|
|
874
896
|
const span = t.history.slice(i + 1, next < 0 ? undefined : next + 1);
|
|
875
|
-
if (span.some((h) => failed.includes(h.event))) return [{ outcome: "failed" }];
|
|
897
|
+
if (span.some((h) => failed.includes(h.event) && h.evidenceOnly !== true)) return [{ outcome: "failed" }];
|
|
876
898
|
if (next >= 0 || t.state !== "approved") return []; // handed on without a failure (a relay), or still open
|
|
877
899
|
const accepted = span.find((h) => h.event === "accepted" && h.by === peer);
|
|
878
900
|
// The work stage ends at its first done call: checks and an integration step are not the work itself.
|
|
@@ -1038,6 +1060,129 @@ export class Tasks {
|
|
|
1038
1060
|
wip(peer: PeerId, except?: number): number {
|
|
1039
1061
|
return this.d.board.plannerWip(peer, except);
|
|
1040
1062
|
}
|
|
1063
|
+
|
|
1064
|
+
/** Host-observed task branch/root binding (#845), never a tool-call branch claim or an execution grant. */
|
|
1065
|
+
bindShellBranch(peer: PeerId, id: number, binding: TaskShellBinding & { worktree: string }, current: () => boolean): boolean {
|
|
1066
|
+
try {
|
|
1067
|
+
if (!Number.isSafeInteger(id) || id < 1 || !taskShellBindingValid(binding) || !this.publicMetadataText(binding.branch)
|
|
1068
|
+
|| !current() || this.d.privateTurn?.(peer)) return false;
|
|
1069
|
+
const task = this.d.board.get(id), root = this.d.executionRoot?.(peer);
|
|
1070
|
+
if (!task || task.owner !== peer || !["in_progress", "changes_requested"].includes(task.state) || this.isPii(task)
|
|
1071
|
+
|| !root || realPath(root) === realPath(this.d.cwd) || realPath(root) !== realPath(binding.worktree)) return false;
|
|
1072
|
+
const worktree = createHash("sha256").update(realPath(root)).digest("hex");
|
|
1073
|
+
const previous = task.history.findLast(entry => entry.by === HUB && entry.event === "shell branch bound");
|
|
1074
|
+
if (previous) {
|
|
1075
|
+
const saved: unknown = JSON.parse(previous.note ?? "");
|
|
1076
|
+
if (!saved || typeof saved !== "object" || Array.isArray(saved)) return false;
|
|
1077
|
+
const record = saved as Record<string, unknown>;
|
|
1078
|
+
if (record.branch !== binding.branch || record.worktree !== worktree) return false;
|
|
1079
|
+
}
|
|
1080
|
+
if (task.refs.branch !== undefined && task.refs.branch !== binding.branch) return false;
|
|
1081
|
+
const scope = this.repositories.scope(task.refs);
|
|
1082
|
+
const others = this.d.board.list().filter(row => row.id !== id && !isClosedTaskState(row.state))
|
|
1083
|
+
.map(row => ({ row, scope: this.repositories.scope(row.refs) }));
|
|
1084
|
+
if (scope.key !== this.repositories.project.key || others.some(other => other.scope.key === scope.key && other.row.refs.branch === binding.branch)) return false;
|
|
1085
|
+
if (!current() || this.d.privateTurn?.(peer) || this.d.board.get(id)?.history.length !== task.history.length) return false;
|
|
1086
|
+
if (previous) return true;
|
|
1087
|
+
let recorded = false;
|
|
1088
|
+
this.releaseNotice(`task #${id}: execution branch binding unavailable`, () => {
|
|
1089
|
+
this.d.board.update(id, HUB, "shell branch bound", { refs: { branch: binding.branch } }, JSON.stringify({ branch: binding.branch, worktree }), {}, task.history.length);
|
|
1090
|
+
recorded = true;
|
|
1091
|
+
});
|
|
1092
|
+
return recorded;
|
|
1093
|
+
} catch { return false; }
|
|
1094
|
+
}
|
|
1095
|
+
private capacityBefore(task: Task): Map<PeerId, number> {
|
|
1096
|
+
return new Map([...new Set([task.owner, task.reserved])].filter((peer): peer is PeerId => !!peer).map(peer => [peer, this.wip(peer)]));
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/** Write-driven wake-ups (#785): one unsettled ids-only task notice per planner, never a polling pull. */
|
|
1100
|
+
private writePlannerNotices(task: Task, before: ReadonlyMap<PeerId, number>, approved = false): void {
|
|
1101
|
+
this.releaseNotice(`task #${task.id}: could not notify planner about ready work`, () => {
|
|
1102
|
+
const changes = new Map<PeerId, { capacity: Set<PeerId>; ready: Set<number> }>();
|
|
1103
|
+
const add = (planner: PeerId, peer?: PeerId, id?: number) => {
|
|
1104
|
+
if (!this.d.bus.isRostered(planner)) return;
|
|
1105
|
+
const change = changes.get(planner) ?? { capacity: new Set<PeerId>(), ready: new Set<number>() };
|
|
1106
|
+
if (peer) change.capacity.add(peer);
|
|
1107
|
+
if (id !== undefined) change.ready.add(id);
|
|
1108
|
+
changes.set(planner, change);
|
|
1109
|
+
};
|
|
1110
|
+
const released = new Set<PeerId>();
|
|
1111
|
+
for (const [peer, used] of before) if (this.wip(peer) < used) { released.add(peer); for (const planner of this.taskPlanner(task)) add(planner, peer); }
|
|
1112
|
+
if (approved) for (const card of this.d.board.list("backlog")) {
|
|
1113
|
+
if (card.deps?.includes(task.id) && !this.waitsFor(card).length) for (const planner of this.taskPlanner(card)) add(planner, undefined, card.id);
|
|
1114
|
+
}
|
|
1115
|
+
for (const [planner, change] of changes) this.releaseNotice(`could not notify planner ${planner} about ready work`, () => {
|
|
1116
|
+
const pending = this.d.bus.pendingEnvelopes(planner).filter(env => env.from === HUB && env.kind === "task" && env.body.startsWith("Planner work ready:"));
|
|
1117
|
+
// The bus retains these hub-written lines across restart; an in-memory cache would lose unsettled events.
|
|
1118
|
+
for (const env of pending) for (const line of env.body.split("\n")) {
|
|
1119
|
+
const capacity = /^capacity_released: (\S+) WIP \d+; reserved ready (?:#\d+|none)$/.exec(line);
|
|
1120
|
+
if (capacity) change.capacity.add(capacity[1]!);
|
|
1121
|
+
const ready = /^backlog_ready: #(\d+)$/.exec(line);
|
|
1122
|
+
if (ready && Number.isSafeInteger(Number(ready[1]))) change.ready.add(Number(ready[1]));
|
|
1123
|
+
}
|
|
1124
|
+
const lines: string[] = [];
|
|
1125
|
+
for (const peer of change.capacity) {
|
|
1126
|
+
const top = this.topReservedReady(peer, planner);
|
|
1127
|
+
lines.push(`capacity_released: ${peer} WIP ${this.wip(peer)}; reserved ready ${top ? `#${top.id}` : "none"}`);
|
|
1128
|
+
}
|
|
1129
|
+
for (const id of change.ready) {
|
|
1130
|
+
const card = this.d.board.get(id);
|
|
1131
|
+
if (card?.state === "backlog" && this.taskPlanner(card).includes(planner) && !this.waitsFor(card).length) lines.push(`backlog_ready: #${id}`);
|
|
1132
|
+
else change.ready.delete(id);
|
|
1133
|
+
}
|
|
1134
|
+
for (const env of pending) this.d.bus.withdraw(env.id);
|
|
1135
|
+
const env = newEnvelope(HUB, sanitize(`Planner work ready:\n${lines.join("\n")}\nRead the current board before planning or pulling work.`), { to: [planner], kind: "task" });
|
|
1136
|
+
if (!this.d.bus.publish(env).includes(planner)) throw new Error("the notice was not queued");
|
|
1137
|
+
});
|
|
1138
|
+
// #785 P1c: only this write's own release, never a merged earlier one, and only when the person turned it on.
|
|
1139
|
+
if (this.d.autoPull?.() === true) this.queueAutoPulls(released);
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/** The top card explicitly reserved for `peer` that this planner may pull now; the notice and the opt-in pull read the same rule. `pullable` keeps the staged backlog cards only. */
|
|
1144
|
+
private topReservedReady(peer: PeerId, planner?: PeerId, pullable = false): Task | undefined {
|
|
1145
|
+
return this.d.board.list().filter(card => (pullable ? card.state === "backlog" : ["backlog", "proposed"].includes(card.state)) && !card.owner && card.reserved === peer
|
|
1146
|
+
&& (planner === undefined || this.taskPlanner(card).includes(planner)) && !(pullable && this.isPii(card)) && !card.pinned && taskCanPlan(card) && !this.waitsFor(card).length && !this.excluded(card).includes(peer)
|
|
1147
|
+
&& (card.release == null || ["planning", "active"].includes(this.d.board.release(card.release)?.state ?? ""))).sort(this.readyOrder)[0];
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* #785 P1c: `tasks.auto_pull`. A slot freed for a peer pulls the one top backlog card explicitly reserved for it, through `pullPlanned` as the hub acting for that card's planner of
|
|
1152
|
+
* record: revision, dependency, reservation, pinned, frozen-release, P0 order and WIP checks all run there unchanged. One card per release. Never an unreserved card, never PII.
|
|
1153
|
+
* Serialized on one chain, so two releases cannot pick the same card; each job re-reads the board, so a card that moved meanwhile is skipped.
|
|
1154
|
+
*/
|
|
1155
|
+
private autoPulls: Promise<void> = Promise.resolve();
|
|
1156
|
+
private autoPullsOpen = 0;
|
|
1157
|
+
/** Queued or running auto pulls, so a recovery commit waits for them the way it waits for `checksPending` (#785 P1c): one that is mid-admission would otherwise write after the commit's digest. */
|
|
1158
|
+
autoPullsPending = (): number => this.autoPullsOpen;
|
|
1159
|
+
private queueAutoPulls(released: ReadonlySet<PeerId>): void {
|
|
1160
|
+
for (const peer of released) {
|
|
1161
|
+
this.autoPullsOpen++;
|
|
1162
|
+
this.autoPulls = this.autoPulls.then(() => this.autoPull(peer)).catch(() => { /* autoPull reports its own refusals */ }).finally(() => { this.autoPullsOpen--; });
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
/** Whether the hub may still start work on its own: the setting is on, and no recovery, shutdown or storage hold is active. Read at entry and again right before the assignment write. */
|
|
1166
|
+
private autoPullAdmitted(): boolean {
|
|
1167
|
+
return this.d.autoPull?.() === true && !this.d.sweepHeld?.() && !this.d.bus.isRecoveryHeld && !this.d.bus.storageError;
|
|
1168
|
+
}
|
|
1169
|
+
private async autoPull(peer: PeerId): Promise<void> {
|
|
1170
|
+
if (!this.autoPullAdmitted()) return;
|
|
1171
|
+
const card = this.topReservedReady(peer, undefined, true);
|
|
1172
|
+
if (!card) return;
|
|
1173
|
+
// The planner of record is the card's own: its bound planner, else the first current holder, and it must be rostered. A private card never gets here (topReservedReady skips it).
|
|
1174
|
+
const planner = this.taskPlanner(card)[0];
|
|
1175
|
+
if (!planner || !this.d.bus.isRostered(planner)) return;
|
|
1176
|
+
const reason = `auto_pull: capacity released for ${peer}; hub acted for planner ${planner}`;
|
|
1177
|
+
try {
|
|
1178
|
+
await this.pullPlanned(HUB, { id: card.id, revision: card.planningRevision ?? 0, reason }, { onBehalfOf: planner, guard: () => { if (!this.autoPullAdmitted()) throw new PreEffectToolRefusal("auto pull is no longer admitted: tasks.auto_pull was turned off or a recovery, shutdown or storage hold began"); } });
|
|
1179
|
+
} catch (e) {
|
|
1180
|
+
// A guard refusal is the setting or a hold doing its job, not a failure to report.
|
|
1181
|
+
if (!this.autoPullAdmitted()) return;
|
|
1182
|
+
this.releaseNotice(`task ${this.publicTitle(card)}: auto_pull for ${peer} refused: ${e instanceof Error ? e.message : String(e)}`);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1041
1186
|
/** #784 AC3: the read-only breakdown of `wip` for status, the console and the dashboard: one grouped query for every peer. Admission still reads `wip()`. */
|
|
1042
1187
|
wipBreakdown(peers: readonly PeerId[]): Record<string, { wip: number; active: number; reviewWait: number; reserved: number; wipLimit: number }> {
|
|
1043
1188
|
const grouped = this.d.board.plannerWipBreakdown();
|
|
@@ -1079,15 +1224,20 @@ export class Tasks {
|
|
|
1079
1224
|
return assign({ ...task, state: "proposed" }, states, routing, { exclude: this.excluded(task), excludeOwners: full, ...(reservedOnly && task.reserved ? { candidates: [task.reserved], namedOwner: task.reserved } : {}), waitsFor: this.waitsFor(task), ...this.weights(task.class, task) });
|
|
1080
1225
|
}
|
|
1081
1226
|
|
|
1082
|
-
async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }): Promise<Task> {
|
|
1227
|
+
async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }, opts: { onBehalfOf?: PeerId; guard?: () => void } = {}): Promise<Task> {
|
|
1083
1228
|
// #455: one authority check, made before the screen and again after it (a person may move the planner meanwhile).
|
|
1084
|
-
|
|
1229
|
+
// #785 P1c: `by` is the actor the history records; the hub's opt-in auto pull names the planner it acts for, and that planner's authority is what is checked.
|
|
1230
|
+
if (opts.onBehalfOf !== undefined && by !== HUB) throw new PreEffectToolRefusal("only the hub pulls on a planner's behalf");
|
|
1231
|
+
const planner = opts.onBehalfOf ?? by;
|
|
1232
|
+
// `opts.guard` (the auto pull's own admission, #785 P1c) throws a PreEffectToolRefusal once the pull is no longer wanted; it runs wherever authority is rechecked, so it
|
|
1233
|
+
// also runs after the awaited PII screen and right before the assignment write, where a setting turned off or a recovery that began meanwhile must still stop it.
|
|
1234
|
+
const authorized = () => { this.planningAuthority(planner); opts.guard?.(); };
|
|
1085
1235
|
authorized();
|
|
1086
1236
|
if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0 || typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("planner pull needs an id, revision and bounded reason");
|
|
1087
1237
|
const validate = () => {
|
|
1088
1238
|
const task = this.d.board.get(input.id);
|
|
1089
1239
|
if (!task || (task.planningRevision ?? 0) !== input.revision || task.state !== "backlog" || task.owner || !taskCanPlan(task) || task.pinned) throw new PreEffectToolRefusal("planner pull changed, pinned or already started");
|
|
1090
|
-
this.planningAuthority(
|
|
1240
|
+
this.planningAuthority(planner, task);
|
|
1091
1241
|
const release = task.release == null ? undefined : this.d.board.release(task.release);
|
|
1092
1242
|
if (task.release != null && (!release || ["frozen", "shipped", "dropped"].includes(release.state))) throw new PreEffectToolRefusal("planner pull requires unfrozen release scope");
|
|
1093
1243
|
if (this.waitsFor(task).length) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
|
|
@@ -1121,6 +1271,7 @@ export class Tasks {
|
|
|
1121
1271
|
if (checked.task.history.length !== current.task.history.length || checked.owner !== current.owner) throw new PreEffectToolRefusal("planner pull changed during admission");
|
|
1122
1272
|
} } });
|
|
1123
1273
|
}
|
|
1274
|
+
opts.guard?.();
|
|
1124
1275
|
const pulled = this.d.board.pullPlanned(current.task.id, by, current.owner, input.revision, input.reason.trim(), withheld, this.plannerBinding(current.task, current.assignment, true));
|
|
1125
1276
|
this.planningRecorded({ by, task: pulled.id, fields: ["pull", "reserved"], revision: pulled.planningRevision! });
|
|
1126
1277
|
return pulled;
|
|
@@ -1380,7 +1531,9 @@ export class Tasks {
|
|
|
1380
1531
|
if (entry.planning.pull) {
|
|
1381
1532
|
const release = task.release == null ? undefined : this.d.board.release(task.release);
|
|
1382
1533
|
if (task.state !== "proposed" || task.owner || taskHasStarted(task) || by !== USER && (task.pinned || release && ["frozen", "shipped", "dropped"].includes(release.state))) throw new PreEffectToolRefusal("planner pull undo is no longer eligible");
|
|
1534
|
+
const capacity = this.capacityBefore(task);
|
|
1383
1535
|
const returned = this.d.board.undoPlannedPull(id, by, revision);
|
|
1536
|
+
this.writePlannerNotices(returned, capacity);
|
|
1384
1537
|
this.offered.delete(id); this.incomingBacklog.add(id);
|
|
1385
1538
|
this.planningRecorded({ by, task: id, fields: ["pull", "reserved"], revision: returned.planningRevision! });
|
|
1386
1539
|
return returned;
|
|
@@ -1760,7 +1913,9 @@ export class Tasks {
|
|
|
1760
1913
|
this.plannerWriteAuthority(by);
|
|
1761
1914
|
const confirmed = validate();
|
|
1762
1915
|
if (confirmed.history.length !== flow) throw new PreEffectToolRefusal("task changed during untouched return");
|
|
1916
|
+
const capacity = this.capacityBefore(confirmed);
|
|
1763
1917
|
const returned = this.d.board.update(confirmed.id, by, "backlog returned untouched", { state: "backlog", owner: null, reviewer: null }, input.reason.trim(), { ...(withheld ? { withheld: true } : {}) }, flow);
|
|
1918
|
+
this.writePlannerNotices(returned, capacity);
|
|
1764
1919
|
this.offered.delete(confirmed.id);
|
|
1765
1920
|
this.cohorts.leave(confirmed.id);
|
|
1766
1921
|
this.withdrawRoleRequest(confirmed, returned, "owner");
|
|
@@ -1783,7 +1938,9 @@ export class Tasks {
|
|
|
1783
1938
|
if (!pull && taskHasStarted(task)) throw new PreEffectToolRefusal(`task #${n}: started work cannot return to backlog`);
|
|
1784
1939
|
const pulled = task.history.findLast(entry => entry.event === "backlog pulled" || entry.event === "backlog returned");
|
|
1785
1940
|
const restoreReservation = !pull && pulled?.planning?.pull && !task.history.slice(task.history.lastIndexOf(pulled) + 1).some(entry => entry.event === "reserved" || entry.planning?.fields.includes("reserved"));
|
|
1941
|
+
const capacity = this.capacityBefore(task);
|
|
1786
1942
|
const next = this.d.board.update(n, by, pull ? "backlog pulled" : "backlog returned", { state: pull ? "proposed" : "backlog", owner: null, reviewer: null, ...(restoreReservation ? { reserved: pulled.planning!.previous.reserved ?? null } : {}) });
|
|
1943
|
+
this.writePlannerNotices(next, capacity);
|
|
1787
1944
|
this.offered.delete(n);
|
|
1788
1945
|
if (!pull) { this.incomingBacklog.add(n); return next; }
|
|
1789
1946
|
const waits = this.waitsFor(next);
|
|
@@ -2612,7 +2769,10 @@ export class Tasks {
|
|
|
2612
2769
|
const task = validate();
|
|
2613
2770
|
if (task.history.length !== revision) throw new PreEffectToolRefusal("task flow revision changed");
|
|
2614
2771
|
const cancelled = { reason: input.reason.trim(), ...(input.supersededBy ? { supersededBy: input.supersededBy.trim() } : {}), flowRevision: revision + 1, ...(withheld ? { withheld: true as const } : {}) };
|
|
2772
|
+
const capacity = this.capacityBefore(task);
|
|
2773
|
+
this.ciWatchers.cancel(task.id); this.heldReviews.delete(task.id);
|
|
2615
2774
|
const next = this.d.board.update(task.id, by, "cancelled", { state: "cancelled", reserved: null, cancelled }, cancelled.reason, { ...(withheld ? { withheld: true as const } : {}) }, revision);
|
|
2775
|
+
this.writePlannerNotices(next, capacity);
|
|
2616
2776
|
this.releaseNotice(`task #${task.id}: cancellation cohort cleanup unavailable`, () => this.cohorts.leave(task.id));
|
|
2617
2777
|
this.offered.delete(task.id);
|
|
2618
2778
|
this.releaseNotice(`task #${task.id}: cancellation stall cleanup unavailable`, () => this.d.board.clearStall(task.id));
|
|
@@ -2863,7 +3023,7 @@ export class Tasks {
|
|
|
2863
3023
|
}
|
|
2864
3024
|
const rows = this.d.board.list();
|
|
2865
3025
|
const tasks: MetricTask[] = rows.map(task => ({ id: task.id, private: this.isPii(task), history: task.history.map(entry => ({ at: entry.at, event: entry.event,
|
|
2866
|
-
...(entry.reviewCost?.head ? { requestHead: entry.reviewCost.head } : {}), ...(entry.reviewFindings?.head ? { verdictHead: entry.reviewFindings.head } : {}) })) }));
|
|
3026
|
+
...(entry.reviewCost?.head ? { requestHead: entry.reviewCost.head } : {}), ...(entry.reviewFindings?.head ? { verdictHead: entry.reviewFindings.head } : {}), ...(entry.evidenceOnly ? { evidenceOnly: true as const } : {}) })) }));
|
|
2867
3027
|
const visibleIds = new Set(rows.filter(task => !this.isPii(task)).map(task => task.id));
|
|
2868
3028
|
const byKind: Record<string, number> = {};
|
|
2869
3029
|
for (const stall of this.stalled()) if (visibleIds.has(stall.task)) byKind[stall.cause.kind] = (byKind[stall.cause.kind] ?? 0) + 1;
|
|
@@ -3688,12 +3848,17 @@ export class Tasks {
|
|
|
3688
3848
|
const task = this.need(id, true, true);
|
|
3689
3849
|
this.mine(task, by, "owner", true);
|
|
3690
3850
|
// The owner it is declined for goes on the entry, also when the console declines for it: `excluded()` keeps it out (#207).
|
|
3851
|
+
const capacityBefore = this.capacityBefore(task);
|
|
3691
3852
|
const planned = this.plannerPullRecord(task)?.pull === true;
|
|
3692
3853
|
const back = this.d.board.update(task.id, by, "declined", { ...(["in_progress", "changes_requested"].includes(task.state) ? { state: "proposed" as const } : {}), ...(planned ? { reserved: null } : {}) }, reason, task.owner ? { from: task.owner, ...(capacity === true ? { capacity: true as const } : {}) } : {});
|
|
3693
3854
|
if (planned) this.offered.delete(task.id);
|
|
3694
3855
|
this.d.notify(`task ${this.publicTitle(back)} declined${capacity === true ? " (capacity only)" : ""} by ${by}${reason && !this.isPii(back) ? `: ${reason}` : ""}`);
|
|
3695
3856
|
// A capacity decline is not in excluded(), so this move's own reroute leaves the decliner out explicitly.
|
|
3696
|
-
|
|
3857
|
+
try {
|
|
3858
|
+
return await this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true, ...(capacity === true && task.owner ? { exclude: [task.owner] } : {}), ...(planned ? { planner: true } : {}) });
|
|
3859
|
+
} finally {
|
|
3860
|
+
this.writePlannerNotices(this.d.board.get(task.id) ?? back, capacityBefore);
|
|
3861
|
+
}
|
|
3697
3862
|
}
|
|
3698
3863
|
|
|
3699
3864
|
/**
|
|
@@ -4037,7 +4202,10 @@ export class Tasks {
|
|
|
4037
4202
|
const completedRefs = cleanRefs(refs);
|
|
4038
4203
|
this.warmRepository({ ...task, refs: { ...task.refs, ...completedRefs } });
|
|
4039
4204
|
const noWrites = this.noWritesRecorded({ ...task, refs: { ...task.refs, ...completedRefs } });
|
|
4205
|
+
const capacity = this.capacityBefore(task);
|
|
4040
4206
|
const next = this.d.board.update(task.id, by, "done", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...completedRefs } }), state: reviewer ? "in_review" : "approved", ...(selection ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { ...this.planObservation(task), withheld: withheld || undefined, ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection), ...(reviewCost ? { reviewCost } : {}), ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
|
|
4207
|
+
this.writePlannerNotices(next, capacity, next.state === "approved");
|
|
4208
|
+
this.ciWatchers.cancel(next.id); this.heldReviews.delete(next.id); // a newer head or a move ends the old head's watcher (#785 P1b)
|
|
4041
4209
|
this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
|
|
4042
4210
|
const memorySymbols = this.completionMemorySymbols(next);
|
|
4043
4211
|
this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\nChanged-symbol memory anchors: ${memorySymbols.state === "known" ? memorySymbols.nodes.length : "unknown (no complete code facts)"}\n${summary ?? ""}`, withheld, memorySymbols);
|
|
@@ -4050,6 +4218,7 @@ export class Tasks {
|
|
|
4050
4218
|
}
|
|
4051
4219
|
else if (reviewer === USER) this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}: review it in the ahub console (Tasks, select #${next.id}, press r) or with ahub review ${next.id} approved|changes_requested [note]`);
|
|
4052
4220
|
else this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${reviewer}`, () => this.sendReview(next, reviewer, "", admission));
|
|
4221
|
+
if (reviewer && !(admission?.ready && admission.head === this.reviewHead(next))) this.watchCi(next);
|
|
4053
4222
|
return next;
|
|
4054
4223
|
}
|
|
4055
4224
|
|
|
@@ -4215,6 +4384,7 @@ export class Tasks {
|
|
|
4215
4384
|
if (mode === "enforce" && !admission?.ready) {
|
|
4216
4385
|
// Background roster/quota role changes are retained, but never invent a remotely admitted review request.
|
|
4217
4386
|
this.releaseNotice(`task ${this.publicTitle(task)}: premature review held; retry the reviewer assignment after current CI and bot replies are verified`);
|
|
4387
|
+
this.holdReview(task, reviewer);
|
|
4218
4388
|
return false;
|
|
4219
4389
|
}
|
|
4220
4390
|
|
|
@@ -4235,9 +4405,12 @@ export class Tasks {
|
|
|
4235
4405
|
? "No check ran for this class: verify the outcome yourself."
|
|
4236
4406
|
: "No check ran for this class: run the affected tests yourself.";
|
|
4237
4407
|
// #824: the hub's own exact-head CI evidence, from the admission this request already carries (no new gh call). Nothing here comes from the builder's summary or a recorded claim.
|
|
4238
|
-
const
|
|
4408
|
+
const codeScoped = this.d.reviewScope?.(reviewer) === "code";
|
|
4409
|
+
const verified = codeScoped ? "" : this.hubVerifiedBlock(task, admission);
|
|
4239
4410
|
const ciPassed = admission?.ready === true && !this.isPii(task);
|
|
4240
|
-
|
|
4411
|
+
// #852 AC2: a code-scope reviewer's sandbox cannot run tests or read CI, so its Hub-verified and check lines are replaced by one fixed line.
|
|
4412
|
+
const checkResult = codeScoped ? CODE_SCOPE_LINE
|
|
4413
|
+
: check ? `Check result: ${(check.note ?? "").split("\n")[0]}`
|
|
4241
4414
|
: ciPassed ? "CI passed at this exact head (see the Hub-verified line above); do not list test execution or CI evidence as unmet: name the specific missing test or behaviour instead."
|
|
4242
4415
|
: noCheckWording;
|
|
4243
4416
|
const checklistTemplates = profile.review_checklist ?? [
|
|
@@ -4291,6 +4464,67 @@ export class Tasks {
|
|
|
4291
4464
|
return this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, ...(this.reviewHead(task) ? { commit: this.reviewHead(task)! } : {}), task: String(task.id), reviewRevision: String(this.reviewRequestRevision(task)) }, ...(this.isPii(task) ? { private: true } : {}) })).includes(reviewer);
|
|
4292
4465
|
}
|
|
4293
4466
|
|
|
4467
|
+
/** #785 P1b: remember a review the hub held as premature, so a green CI at this exact head can re-offer it once. Also watches that head. */
|
|
4468
|
+
private holdReview(task: Task, reviewer: PeerId): void {
|
|
4469
|
+
try {
|
|
4470
|
+
const head = this.reviewHead(task);
|
|
4471
|
+
if (this.isPii(task) || !head || reviewer === USER) return;
|
|
4472
|
+
this.heldReviews.set(task.id, { head, reviewer, revision: this.reviewRequestRevision(task) });
|
|
4473
|
+
this.watchCi(task);
|
|
4474
|
+
} catch { /* optional bookkeeping never fails a review path */ }
|
|
4475
|
+
}
|
|
4476
|
+
|
|
4477
|
+
/** #785 P1b AC3: start the one watcher for this task's exact head. Not for private work, an unlinked PR or a lifecycle that is off; never throws. */
|
|
4478
|
+
private watchCi(task: Task): void {
|
|
4479
|
+
try {
|
|
4480
|
+
const head = this.reviewHead(task), repo = task.sdlc?.repo, pr = task.sdlc?.pr;
|
|
4481
|
+
if (!this.d.sdlc || this.d.sdlc.config().mode === "off" || this.isPii(task) || task.state !== "in_review" || !head || !/^[0-9a-f]{40}$/.test(head) || !repo || !pr) return;
|
|
4482
|
+
this.ciWatchers.start({ id: task.id, head, repo, pr });
|
|
4483
|
+
} catch { /* a watcher that cannot start leaves the review path as it was */ }
|
|
4484
|
+
}
|
|
4485
|
+
|
|
4486
|
+
/** The watcher's own check before every read and every notice: the task must still be in review at this exact head and PR. */
|
|
4487
|
+
private ciWatchCurrent(watch: CiWatch): boolean {
|
|
4488
|
+
const task = this.d.board.get(watch.id);
|
|
4489
|
+
return !!task && task.state === "in_review" && !this.isPii(task) && this.reviewHead(task) === watch.head && task.sdlc?.repo === watch.repo && task.sdlc?.pr === watch.pr
|
|
4490
|
+
&& (this.d.sdlc?.config().mode ?? "off") !== "off";
|
|
4491
|
+
}
|
|
4492
|
+
|
|
4493
|
+
/**
|
|
4494
|
+
* #785 P1b: the result of the exact head's CI, told once to the owner and the reviewer (one ids-only task envelope each, `status` weight: `fyi` is recorded but never delivered to the peer),
|
|
4495
|
+
* then a review held as premature is offered once more after a fresh readiness read. A deadline tells the console only.
|
|
4496
|
+
*/
|
|
4497
|
+
private async ciCompleted(watch: CiWatch, outcome: CiOutcome): Promise<void> {
|
|
4498
|
+
const task = this.d.board.get(watch.id);
|
|
4499
|
+
if (!task || !this.ciWatchCurrent(watch)) return;
|
|
4500
|
+
const label = `#${task.id} head ${watch.head.slice(0, 12)}`;
|
|
4501
|
+
if (outcome === "deadline") { this.releaseNotice(`task ${label}: CI did not finish within the watch deadline; no further reads`); return; }
|
|
4502
|
+
const line = `ci_completed: ${label} ${outcome}`;
|
|
4503
|
+
for (const peer of new Set([task.owner, task.reviewer])) {
|
|
4504
|
+
if (!peer || peer === HUB) continue;
|
|
4505
|
+
if (peer === USER) { this.releaseNotice(`task ${label}: CI ${outcome} at its exact head`); continue; }
|
|
4506
|
+
this.releaseNotice(`task ${label}: could not tell ${peer} the CI result`, () => this.d.bus.publish(newEnvelope(HUB, `${line}\nRead hub_task_show for the task before acting.`, { to: [peer], kind: "task", priority: "status", refs: { task: String(task.id), commit: watch.head } })));
|
|
4507
|
+
}
|
|
4508
|
+
if (outcome === "failed") return;
|
|
4509
|
+
await this.reofferHeldReview(task, watch);
|
|
4510
|
+
}
|
|
4511
|
+
|
|
4512
|
+
/** The held review of this exact head and request, once: only while it is still the same reviewer, head and revision, and a fresh admission says it is ready. */
|
|
4513
|
+
private async reofferHeldReview(task: Task, watch: CiWatch): Promise<void> {
|
|
4514
|
+
const held = this.heldReviews.get(task.id);
|
|
4515
|
+
if (!held || held.head !== watch.head || task.reviewer !== held.reviewer || this.reviewRequestRevision(task) !== held.revision) return;
|
|
4516
|
+
this.heldReviews.delete(task.id); // once: a later hold needs a new head or a new request
|
|
4517
|
+
try {
|
|
4518
|
+
const admission = await this.reviewRequestAdmission(task, undefined, false, task.owner ?? USER);
|
|
4519
|
+
const current = this.d.board.get(task.id);
|
|
4520
|
+
if (!admission?.ready || !current || !this.ciWatchCurrent(watch) || current.reviewer !== held.reviewer || this.reviewRequestRevision(current) !== held.revision) return;
|
|
4521
|
+
this.releaseNotice(`task ${this.publicTitle(current)}: could not re-offer the held review to ${held.reviewer}`, () => this.sendReview(current, held.reviewer, "(Held earlier because exact-head CI was not green; it is green now.)", admission));
|
|
4522
|
+
} catch { /* still not ready: the hold notice already named why; a reviewer reassignment retries it */ }
|
|
4523
|
+
}
|
|
4524
|
+
|
|
4525
|
+
/** Daemon shutdown: no watcher outlives the hub. */
|
|
4526
|
+
stopCiWatchers(): void { this.ciWatchers.stopAll(); this.heldReviews.clear(); }
|
|
4527
|
+
|
|
4294
4528
|
private reviewRequestRevision(task: Task): number {
|
|
4295
4529
|
// Role changes fence older requests; bookkeeping appended after the request does not.
|
|
4296
4530
|
return task.history.findLastIndex(entry => ["done", "reviewer changed", "reviewer unavailable"].includes(entry.event) || OWNERSHIP_EVENTS.has(entry.event)) + 1;
|
|
@@ -4431,6 +4665,11 @@ export class Tasks {
|
|
|
4431
4665
|
let parsed: ReviewFinding[];
|
|
4432
4666
|
try { parsed = parseReviewFindings(findings, { person: by === USER, maxNits: this.d.sdlc?.config().maxNits ?? DEFAULT_MAX_NITS }); }
|
|
4433
4667
|
catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "invalid structured findings"); }
|
|
4668
|
+
// #852 AC3 (amended): a code-scope reviewer's changes_requested is always recorded, never refused. Without an open structured finding it is
|
|
4669
|
+
// evidence-only: it does not count toward rejections or escalation, the task returns to its owner as usual, and the conductor gets one ids-only
|
|
4670
|
+
// notice. The rule is structural (a finding with disposition open), never a reading of the unmet text.
|
|
4671
|
+
const codeScope = by !== USER && this.d.reviewScope?.(by) === "code";
|
|
4672
|
+
const evidenceOnly = codeScope && verdict === "changes_requested" && !parsed.some(finding => finding.disposition === "open");
|
|
4434
4673
|
const resolvedHead = this.findingHead(initial);
|
|
4435
4674
|
let findingHead = resolvedHead;
|
|
4436
4675
|
if (head !== undefined) {
|
|
@@ -4512,7 +4751,7 @@ export class Tasks {
|
|
|
4512
4751
|
}
|
|
4513
4752
|
if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "approval", reviewedHead, proposedSets(task)); task = valid(); }
|
|
4514
4753
|
let candidate: Extract<CandidateInput, { kind: "fail" }> | undefined;
|
|
4515
|
-
if (verdict === "changes_requested" && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
|
|
4754
|
+
if (verdict === "changes_requested" && !evidenceOnly && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
|
|
4516
4755
|
const input: CandidateInput = { project: this.d.store?.project ?? this.d.project, kind: "fail", approach: this.publicTitle(task), symptom: items.join("\n"),
|
|
4517
4756
|
cause: "unknown: review unmet items do not establish a cause", retryWhen: "unknown: a person must review the unmet items before accepting",
|
|
4518
4757
|
evidenceRefs: [`task:${task.id}`, `reviewed-head:${reviewedHead ?? task.refs.commit ?? "unknown"}`] };
|
|
@@ -4531,9 +4770,12 @@ export class Tasks {
|
|
|
4531
4770
|
// #507b: unmet items that start with a recorded criterion id name that criterion. The ids come from the checklist on the board as it is at this
|
|
4532
4771
|
// write (the `task` just rechecked by valid()), never from the caller; a PII task carries none, its checklist being private.
|
|
4533
4772
|
const unmetCriteria = pii ? [] : unmetCriteriaIds(items, checklistOf(task.sdlc));
|
|
4534
|
-
const flag = { ...this.planObservation(task), withheld: withheld || undefined, ...(report ? { reviewFindings: report } : {}), ...(unmetCriteria.length ? { unmetCriteria } : {}), ...(boundDeliverables ? { deliverables: boundDeliverables } : {}), ...(reviewContext ? { reviewContext } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
|
|
4773
|
+
const flag = { ...this.planObservation(task), withheld: withheld || undefined, ...(report ? { reviewFindings: report } : {}), ...(unmetCriteria.length ? { unmetCriteria } : {}), ...(boundDeliverables ? { deliverables: boundDeliverables } : {}), ...(reviewContext ? { reviewContext } : {}), ...(codeScope ? { reviewScope: "code" as const } : {}), ...(evidenceOnly ? { evidenceOnly: true as const } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
|
|
4535
4774
|
if (verdict === "approved") {
|
|
4775
|
+
const capacity = this.capacityBefore(task);
|
|
4776
|
+
this.ciWatchers.cancel(task.id); this.heldReviews.delete(task.id);
|
|
4536
4777
|
const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0, ...(reviewedHead && task.sdlc ? { sdlc: { ...task.sdlc, reviewedHead, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), "merge") } } : {}) }, note, flag);
|
|
4778
|
+
this.writePlannerNotices(next, capacity, next.state === "approved");
|
|
4537
4779
|
if (next.owner) {
|
|
4538
4780
|
this.d.board.recordOutcome(next.owner, next.class, true);
|
|
4539
4781
|
this.d.board.recordReview({ implementer: next.owner, reviewer: by, class: next.class, kind: "approved", task: next.id });
|
|
@@ -4552,17 +4794,20 @@ export class Tasks {
|
|
|
4552
4794
|
this.releaseNotice(`task #${next.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(next, by, parsed, withheld));
|
|
4553
4795
|
return next;
|
|
4554
4796
|
}
|
|
4555
|
-
|
|
4797
|
+
this.ciWatchers.cancel(task.id); this.heldReviews.delete(task.id);
|
|
4798
|
+
const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: evidenceOnly ? task.rejections : task.rejections + 1 }, note, flag);
|
|
4556
4799
|
void this.mirrorVerdict(rejected, by, "changes_requested", reviewedHead ?? rejected.sdlc?.reviewedHead, note, withheld);
|
|
4557
|
-
this.releaseNotice(`task #${rejected.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(rejected, by, parsed, withheld));
|
|
4800
|
+
if (!evidenceOnly) this.releaseNotice(`task #${rejected.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(rejected, by, parsed, withheld));
|
|
4558
4801
|
if (candidate && !pii && !this.d.privateTurn?.(by) && this.candidateTextAllowed(JSON.stringify({ candidate, refs: rejected.refs, plan: rejected.plan }), rejected)) {
|
|
4559
4802
|
try { this.d.memoryCheck?.(); this.d.candidates?.put({ ...candidate, evidenceRefs: [...candidate.evidenceRefs, `review:${rejected.id}:${rejected.history.length}`] }, "public"); } catch { /* a saved review remains authoritative if the optional sidecar fails */ }
|
|
4560
4803
|
}
|
|
4561
4804
|
this.cohorts.withdraw(rejected.id); // open again: its done intent no longer counts (issue #107)
|
|
4562
|
-
|
|
4563
|
-
this.
|
|
4805
|
+
// Evidence-only is not a verdict on the owner's work: no failed outcome, no contradiction of an earlier approval, and no escalation count.
|
|
4806
|
+
if (rejected.owner && !evidenceOnly) this.d.board.recordOutcome(rejected.owner, rejected.class, false);
|
|
4807
|
+
if (!evidenceOnly) this.contradict(rejected);
|
|
4564
4808
|
this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`, withheld);
|
|
4565
|
-
if (
|
|
4809
|
+
if (evidenceOnly) this.tellConductorEvidenceOnly(rejected, by);
|
|
4810
|
+
if (!evidenceOnly && rejected.rejections >= (this.d.escalateAfter?.() ?? ESCALATE_AFTER)) {
|
|
4566
4811
|
let moved: Task;
|
|
4567
4812
|
try {
|
|
4568
4813
|
moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`, "rejections");
|
|
@@ -4587,6 +4832,19 @@ export class Tasks {
|
|
|
4587
4832
|
return reopened;
|
|
4588
4833
|
}
|
|
4589
4834
|
|
|
4835
|
+
/**
|
|
4836
|
+
* #852 AC3: one task-weight notice to the configured conductor for a verdict recorded as evidence-only. Ids only (task and reviewer), so nothing of
|
|
4837
|
+
* the review or the task reaches it; a PII task's notice is private. With no conductor the person sees one console notice instead.
|
|
4838
|
+
*/
|
|
4839
|
+
private tellConductorEvidenceOnly(task: Task, reviewer: PeerId): void {
|
|
4840
|
+
const conductors = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor).filter(actor => actor !== reviewer);
|
|
4841
|
+
const text = evidenceOnlyNotice(task.id, reviewer);
|
|
4842
|
+
if (!conductors.length) { this.releaseNotice(text); return; }
|
|
4843
|
+
this.releaseNotice(`task #${task.id}: could not tell the conductor a code-scope review was evidence-only`, () => {
|
|
4844
|
+
this.d.bus.publish(newEnvelope(HUB, sanitize(text), { to: conductors, kind: "task", refs: { task: String(task.id) }, ...(this.isPii(task) ? { private: true } : {}) }));
|
|
4845
|
+
});
|
|
4846
|
+
}
|
|
4847
|
+
|
|
4590
4848
|
/** Task-level escalation: the next attached peer in the class's escalate_to takes over, with the history. */
|
|
4591
4849
|
async escalate(by: PeerId, id: unknown, why = "by hand", reason: TaskMoveReason | undefined = by === HUB ? undefined : "manual", preEffect = false): Promise<Task> {
|
|
4592
4850
|
let task = this.need(id, true, preEffect);
|