@staix/agent-hub 0.12.30 → 0.12.32
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 +33 -0
- package/README.md +29 -9
- package/docs/agent-notes/adapters.md +9 -5
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/bus.md +5 -0
- package/docs/agent-notes/daemon.md +14 -8
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +7 -1
- package/docs/agent-notes/tests.md +6 -2
- package/docs/cooperbench.md +1 -1
- package/docs/gsd-collaboration-analysis.md +1 -1
- package/docs/memory.md +2 -2
- package/docs/operations.md +236 -37
- package/docs/quickstart.md +24 -5
- package/docs/security.md +42 -10
- package/docs/smoke.md +34 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +241 -3
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +275 -10
- package/docs/verification/2026-10-17-0.12.31.md +71 -0
- package/docs/verification/2026-10-17-418-probes.md +21 -0
- package/docs/verification/2026-10-18-0.12.32.md +54 -0
- package/docs/verified.json +20 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1361 -44
- package/src/adapters/acp.ts +5 -6
- package/src/adapters/claude-channel.ts +29 -11
- package/src/adapters/definitions/builtin-metadata.ts +121 -0
- package/src/adapters/definitions/kimi-native.ts +4 -0
- package/src/adapters/definitions/qwen-native.ts +24 -0
- package/src/adapters/definitions/qwen.ts +71 -0
- package/src/adapters/local-worker.ts +2 -2
- package/src/adapters/pi.ts +11 -2
- package/src/adapters/registry-types.ts +174 -0
- package/src/adapters/registry.ts +36 -0
- package/src/cli/attention.ts +5 -1
- package/src/cli/console-state.ts +130 -17
- package/src/cli/console.ts +54 -8
- package/src/cli/help.ts +3 -1
- package/src/cli/identity.ts +16 -5
- package/src/cli/jcode-mcp.ts +1 -1
- package/src/cli/launch.ts +1 -6
- package/src/cli/main.ts +208 -40
- package/src/cli/output.ts +15 -2
- package/src/cli/preview.ts +8 -5
- package/src/cli/recovery-guidance.ts +137 -0
- package/src/cli/recovery-presentation-options.ts +8 -0
- package/src/cli/recovery-session-file.ts +18 -0
- package/src/cli/terminal-recovery.ts +48 -0
- package/src/cli/upgrade-interactive.ts +129 -74
- package/src/cli/upgrade-labels.ts +30 -0
- package/src/cli/upgrade-presentation-data.ts +41 -0
- package/src/cli/upgrade-presentation-flags.ts +31 -0
- package/src/cli/upgrade-presentation.ts +217 -0
- package/src/cli/upgrade-restore-state.ts +137 -0
- package/src/cli/upgrade-runtime.ts +430 -175
- package/src/cli/upgrade.ts +325 -49
- package/src/hub/board.ts +82 -24
- package/src/hub/bus.ts +89 -29
- package/src/hub/check-runner.ts +514 -0
- package/src/hub/child-process.ts +1 -0
- package/src/hub/cohorts.ts +2 -2
- package/src/hub/config-apply.ts +242 -0
- package/src/hub/config-reload.ts +120 -0
- package/src/hub/config-trust.ts +11 -2
- package/src/hub/control-client.ts +5 -1
- package/src/hub/crash.ts +8 -6
- package/src/hub/daemon.ts +931 -209
- package/src/hub/delivery-hints.ts +30 -0
- package/src/hub/delivery-journal.ts +185 -12
- package/src/hub/delivery-superseded.ts +52 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +2 -2
- package/src/hub/host-executable.ts +11 -0
- package/src/hub/hub-tools.ts +9 -1
- package/src/hub/manager.ts +1 -1
- package/src/hub/peer-catalog.ts +13 -13
- package/src/hub/peers.ts +12 -2
- package/src/hub/permission-mode.ts +5 -3
- package/src/hub/recovery-continuity.ts +152 -0
- package/src/hub/recovery-store.ts +10 -4
- package/src/hub/routing.ts +35 -16
- package/src/hub/sdlc.ts +5 -1
- package/src/hub/settings.ts +52 -37
- package/src/hub/settled-roles.ts +35 -0
- package/src/hub/spec-read.ts +120 -0
- package/src/hub/supervision.ts +24 -8
- package/src/hub/task-sweep.ts +7 -6
- package/src/hub/tasks.ts +199 -40
- package/src/hub/ui.ts +1 -1
- package/src/memory/brief.ts +6 -5
- package/src/memory/capture.ts +2 -1
- package/src/pi/session.ts +23 -4
- package/src/ui/index.html +35 -16
- package/src/ui/needs.ts +7 -5
- package/src/ui/office-model.ts +4 -1
- package/src/ui/task-board.ts +1 -1
- package/src/ui/task-progress.ts +6 -2
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- package/templates/profiles/writing.json +1 -1
package/src/hub/tasks.ts
CHANGED
|
@@ -14,11 +14,11 @@ import { isAbsolute, relative } from "node:path";
|
|
|
14
14
|
import type { Briefs, NativeItem } from "../memory/brief.ts";
|
|
15
15
|
import type { MemoryClient } from "../memory/client.ts";
|
|
16
16
|
import { candidateTargetRevision, type CandidateInput, type CandidateStore, extractNodes, looksSecret, PROMOTED_KINDS, taskKey, type GoalView, type Kind, type MemoryStore, type Scope, type Tier } from "@staix/agent-mem";
|
|
17
|
-
import { CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskHasStarted, planningUndoEntry, type HistoryEntry, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs } from "./board.ts";
|
|
17
|
+
import { CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskHasStarted, planningUndoEntry, isClosedTaskState, type TaskStall, type HistoryEntry, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs } from "./board.ts";
|
|
18
18
|
import type { Bus } from "./bus.ts";
|
|
19
19
|
import { HUB, newEnvelope, NOTE_KINDS, noteLine, sanitize, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
|
|
20
20
|
import type { PiiVerdict, ScreenItem, ScreenRecord } from "./inference.ts";
|
|
21
|
-
import { assign, detectSignals, LOCAL, peerEntry, PI, predictSplit, type Assignment, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
|
|
21
|
+
import { assign, detectSignals, LOCAL, peerEntry, PI, predictSplit, type Assignment, type PeerCapabilities, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
|
|
22
22
|
import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
|
|
23
23
|
import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
|
|
24
24
|
import { realPath } from "./project.ts";
|
|
@@ -78,6 +78,12 @@ export interface TasksDeps {
|
|
|
78
78
|
/** Reviewer choice from recorded review outcomes (issue #35); off unless the project config turns it on. */
|
|
79
79
|
review?: { adaptive: boolean; min_reviews: number };
|
|
80
80
|
/** Peers whose recent deliveries all failed, with the reason (issue #89); routing skips them. */
|
|
81
|
+
capabilities?: () => Record<PeerId, PeerCapabilities>;
|
|
82
|
+
repository?: string;
|
|
83
|
+
offerRead?: (peer: PeerId, envelopeId: string) => boolean;
|
|
84
|
+
offerPullOnly?: (peer: PeerId) => boolean;
|
|
85
|
+
cancellationHeld?: () => boolean;
|
|
86
|
+
stallNotice?: (stall: TaskStall) => void;
|
|
81
87
|
failing?: () => Record<PeerId, string>;
|
|
82
88
|
/** Peers whose queue a needs_review delivery holds, with the hold detail (issue #90); routing explains the hold, never rejects. */
|
|
83
89
|
held?: () => Record<PeerId, string>;
|
|
@@ -91,6 +97,8 @@ export interface TasksDeps {
|
|
|
91
97
|
recordScreen?: (record: ScreenRecord) => void;
|
|
92
98
|
/** Turn-free coordination (issue #107): configured and no PII task open. Asked at each use. */
|
|
93
99
|
turnFree?: () => boolean;
|
|
100
|
+
/** Coordination preference captured for each newly created task; existing rows keep their initial mode. */
|
|
101
|
+
coordination?: () => boolean;
|
|
94
102
|
/** Whether a peer's context path for facts is verified in its current session (issue #108). */
|
|
95
103
|
capable?: (peer: PeerId) => boolean;
|
|
96
104
|
/** Whether `peer` is between native turns now (issue #107): Codex not busy, Claude stopped since its last tool call. */
|
|
@@ -217,10 +225,15 @@ export class Tasks {
|
|
|
217
225
|
private readonly knownPlanners = new Set<PeerId>();
|
|
218
226
|
/** Turn-free cohorts (issue #107): who works without messages, and who integrates. */
|
|
219
227
|
readonly cohorts: Cohorts;
|
|
220
|
-
private readonly
|
|
228
|
+
private readonly coordinationModes = new Map<number, boolean>();
|
|
229
|
+
private sweepConfig: TaskSweepConfig;
|
|
221
230
|
private sweeping = false;
|
|
222
231
|
|
|
223
232
|
constructor(private readonly d: TasksDeps) {
|
|
233
|
+
if (d.coordination) {
|
|
234
|
+
const mode = d.coordination();
|
|
235
|
+
for (const task of d.board.list()) this.coordinationModes.set(task.id, mode);
|
|
236
|
+
}
|
|
224
237
|
const backlog = d.board.list("backlog");
|
|
225
238
|
for (const [peer, roles] of Object.entries(d.roles ?? {})) {
|
|
226
239
|
if (!roles.includes("planner")) continue;
|
|
@@ -304,16 +317,36 @@ export class Tasks {
|
|
|
304
317
|
/** Task-owned, default-off between-turn ladder. Persist before sending so restart cannot repeat a step. */
|
|
305
318
|
async sweep(now = Date.now()): Promise<{ task: number; finding: SweepRecord }[]> {
|
|
306
319
|
const recorded: { task: number; finding: SweepRecord }[] = [];
|
|
307
|
-
|
|
320
|
+
const config = this.sweepConfig;
|
|
321
|
+
if (!config.enabled || this.sweeping || this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) return recorded;
|
|
308
322
|
this.sweeping = true;
|
|
309
323
|
try {
|
|
310
324
|
for (const task of this.d.board.list()) {
|
|
311
325
|
if (this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) break;
|
|
312
|
-
|
|
326
|
+
if (!task.owner && this.personUnassigned(task)) continue;
|
|
327
|
+
const finding = nextSweep(task, config, now, this.waitsFor(task).length === 0);
|
|
313
328
|
if (!finding || this.waitsFor(task).length || this.isChecking(task.id) || this.silentFor(task.id)) continue;
|
|
329
|
+
if (finding.kind === "ready-unassigned") {
|
|
330
|
+
this.d.board.update(task.id, HUB, "idle sweep", {}, undefined, { sweep: finding });
|
|
331
|
+
recorded.push({ task: task.id, finding });
|
|
332
|
+
const planners = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => roles.includes("planner") && this.d.bus.isRostered(peer)).map(([peer]) => peer);
|
|
333
|
+
const text = `Task #${task.id}: ready-unassigned, idle escalation ${finding.step}/3.`;
|
|
334
|
+
if (finding.step === 1 || finding.step === 2) {
|
|
335
|
+
if (finding.step === 2 || !planners.length) this.releaseNotice(text);
|
|
336
|
+
for (const peer of planners) this.releaseNotice(`task #${task.id}: sweep notice unavailable`, () => this.sweepNotice(task, peer, `${text} Check the board and assign, re-plan or cancel it.`, "planner"));
|
|
337
|
+
} else {
|
|
338
|
+
const admission = this.plannerPullRecord(task)?.pull ? this.plannerAssignment(task, true) : assign(task, this.states(), this.d.routing(), { exclude: this.excluded(task), ...this.weights(task.class) });
|
|
339
|
+
this.releaseNotice(`${text} Owner reassignment suggestion: ${admission.owner ?? "no eligible peer"}.`);
|
|
340
|
+
if (admission.owner && config.auto_reassign && this.sweepAvailable(admission.owner)) {
|
|
341
|
+
try { await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [admission.owner], event: "reassigned", reason: "idle", note: "ready-unassigned idle sweep" }); }
|
|
342
|
+
catch (e) { if (!(e instanceof AssignmentUndeliveredError)) throw e; }
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
314
347
|
const responsible = finding.kind === "review-pending" ? task.reviewer! : task.owner!;
|
|
315
348
|
if (responsible !== USER && !this.sweepAvailable(responsible)) continue;
|
|
316
|
-
if (finding.step === 3 && finding.kind !== "review-pending" &&
|
|
349
|
+
if (finding.step === 3 && finding.kind !== "review-pending" && config.auto_reassign && this.plannerPullRecord(task)?.pull && !assign(task, this.states(), this.d.routing(), { exclude: [...this.excluded(task), responsible], excludeOwners: this.plannerFull(task), waitsFor: this.waitsFor(task), ...this.weights(task.class) }).owner) continue;
|
|
317
350
|
const title = this.publicTitle(task);
|
|
318
351
|
const message = `Task ${title}: ${finding.kind}, idle escalation ${finding.step}/3.`;
|
|
319
352
|
// Only identifiers, kind and step are kept in the ladder event. A crash after this write and before publish
|
|
@@ -321,11 +354,11 @@ export class Tasks {
|
|
|
321
354
|
this.d.board.update(task.id, HUB, "idle sweep", {}, undefined, { sweep: finding });
|
|
322
355
|
recorded.push({ task: task.id, finding });
|
|
323
356
|
if (finding.step === 1) {
|
|
324
|
-
this.sweepNotice(task, responsible, `${message} Check its current state with hub_task_list and accept, complete or review your assigned work
|
|
357
|
+
this.sweepNotice(task, responsible, `${message} Check its current state with hub_task_list and accept, complete or review your assigned work.`, finding.kind === "review-pending" ? "reviewer" : "owner");
|
|
325
358
|
} else if (finding.step === 2) {
|
|
326
359
|
this.d.notify(message);
|
|
327
360
|
for (const [peer, roles] of Object.entries(this.d.roles ?? {})) {
|
|
328
|
-
if (peer !== responsible && roles.includes("planner") && this.sweepAvailable(peer)) this.sweepNotice(task, peer, `${message} The assigned ${finding.kind === "review-pending" ? "reviewer" : "owner"} has not recorded fresh task activity; check the board before coordinating
|
|
361
|
+
if (peer !== responsible && roles.includes("planner") && this.sweepAvailable(peer)) this.sweepNotice(task, peer, `${message} The assigned ${finding.kind === "review-pending" ? "reviewer" : "owner"} has not recorded fresh task activity; check the board before coordinating.`, "planner");
|
|
329
362
|
}
|
|
330
363
|
} else {
|
|
331
364
|
const reviewer = finding.kind === "review-pending";
|
|
@@ -333,7 +366,7 @@ export class Tasks {
|
|
|
333
366
|
const candidate = reviewer ? a.reviewer : a.owner;
|
|
334
367
|
this.passedOver(task, a);
|
|
335
368
|
this.d.notify(`${message} ${reviewer ? "Reviewer" : "Owner"} reassignment suggestion: ${candidate ?? "no eligible peer"}.`);
|
|
336
|
-
if (!reviewer && candidate &&
|
|
369
|
+
if (!reviewer && candidate && config.auto_reassign && this.sweepAvailable(candidate)) {
|
|
337
370
|
try {
|
|
338
371
|
await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [candidate], exclude: [responsible], event: "reassigned", reason: "idle", note: `idle sweep: ${finding.kind}; from ${responsible}` });
|
|
339
372
|
} catch (e) {
|
|
@@ -348,14 +381,19 @@ export class Tasks {
|
|
|
348
381
|
return recorded;
|
|
349
382
|
}
|
|
350
383
|
|
|
384
|
+
/** A validated snapshot takes effect on the next sweep, never halfway through one. */
|
|
385
|
+
configureSweep(config: TaskSweepConfig): void {
|
|
386
|
+
this.sweepConfig = taskSweepConfig(config);
|
|
387
|
+
}
|
|
388
|
+
|
|
351
389
|
private sweepAvailable(peer: PeerId): boolean {
|
|
352
390
|
const bus = this.d.bus;
|
|
353
391
|
return bus.stateOf(peer) === "idle" && (this.d.idle?.(peer) ?? true) && !peerEntry(this.d.held?.(), peer) && !bus.queueSummary(peer).heldBy && !bus.queued(peer) && !bus.hasInFlight(peer);
|
|
354
392
|
}
|
|
355
393
|
|
|
356
|
-
private sweepNotice(task: Task, peer: PeerId, body: string): void {
|
|
394
|
+
private sweepNotice(task: Task, peer: PeerId, body: string, taskRecipient: "owner" | "reviewer" | "planner"): void {
|
|
357
395
|
if (peer === USER) this.d.notify(body);
|
|
358
|
-
else this.d.bus.publish(newEnvelope(HUB, body, { to: [peer], kind: "task", refs: { task: String(task.id) } }));
|
|
396
|
+
else this.d.bus.publish(newEnvelope(HUB, body, { to: [peer], kind: "task", refs: { task: String(task.id), taskRecipient } }));
|
|
359
397
|
}
|
|
360
398
|
|
|
361
399
|
configureExecutionBudget = (config: ExecutionBudgetConfig): ExecutionBudgetStatus | undefined => {
|
|
@@ -387,19 +425,20 @@ export class Tasks {
|
|
|
387
425
|
|
|
388
426
|
/** A task as a cloud peer may see it. */
|
|
389
427
|
publicView(task: Task, fullHistory = false): Record<string, unknown> {
|
|
390
|
-
const { title, detail, history, external, ...rest } = task;
|
|
428
|
+
const { title, detail, history, external, cancelled, ...rest } = task;
|
|
391
429
|
if (this.isPii(task)) return { ...rest, actualDates: actualTaskDates(task), refs: {}, plan: {}, ...(task.sdlc ? { sdlc: { stage: stageDefinition(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc.stage)?.base ?? "unknown" } } : {}), title: "[pii]", detail: "[pii]" };
|
|
392
430
|
const screenHistoryText = (text: string, field: string, withheld = false): string => !withheld && this.nameable(text) ? text : `[history ${field} withheld: ${this.whyWithheld(text)}; ahub task show ${task.id}]`;
|
|
393
431
|
const publicHistory = (fullHistory ? history : history.slice(-5)).map(h => ({
|
|
394
432
|
at: h.at, by: h.by, event: h.event,
|
|
395
433
|
...(h.note === undefined ? {} : { note: screenHistoryText(h.note, "note", h.withheld) }),
|
|
434
|
+
...(h.supersededBy === undefined ? {} : { supersededBy: screenHistoryText(h.supersededBy, "supersession", h.withheld) }),
|
|
396
435
|
...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
|
|
397
436
|
...(h.owner === undefined ? {} : { owner: h.owner }),
|
|
398
437
|
...(h.planning ? { planning: { revision: h.planning.revision, fields: h.planning.fields, ...(h.planning.split ? { children: h.planning.split.children.map(child => child.id) } : {}) } } : {}),
|
|
399
438
|
...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
|
|
400
439
|
...(h.sweep ? { sweep: { kind: h.sweep.kind, activity: h.sweep.activity, step: h.sweep.step, at: h.sweep.at } } : {}),
|
|
401
440
|
}));
|
|
402
|
-
return { ...rest, ...(external && this.nameable(external.url) ? { external } : {}), actualDates: actualTaskDates(task), ...(((this.d.sdlc?.config().mode ?? "off") !== "off" || this.d.sdlc?.config().workflow === "gsd-lite") ? { lifecycle: sdlcStatus(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc, undefined, this.workflowView(task)) } : {}), title, detail, history: publicHistory };
|
|
441
|
+
return { ...rest, ...(cancelled ? { cancelled: { ...cancelled, reason: screenHistoryText(cancelled.reason, "cancellation", cancelled.withheld), ...(cancelled.supersededBy ? { supersededBy: screenHistoryText(cancelled.supersededBy, "supersession", cancelled.withheld) } : {}) } } : {}), ...(external && this.nameable(external.url) ? { external } : {}), actualDates: actualTaskDates(task), ...(((this.d.sdlc?.config().mode ?? "off") !== "off" || this.d.sdlc?.config().workflow === "gsd-lite") ? { lifecycle: sdlcStatus(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc, undefined, this.workflowView(task)) } : {}), title, detail, history: publicHistory };
|
|
403
442
|
}
|
|
404
443
|
|
|
405
444
|
/** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
|
|
@@ -470,7 +509,16 @@ export class Tasks {
|
|
|
470
509
|
private health() {
|
|
471
510
|
const failing = this.d.failing?.();
|
|
472
511
|
const held = this.d.held?.();
|
|
473
|
-
return { pullOnly: [...this.d.bus.peers.values()].filter(peer => peer.id === "kimi" && peer.pullOnly).map(peer => peer.id), offRoster: SUPPORTED_PEERS.filter(peer => !this.d.bus.isRostered(peer)), ...(failing ? { failing } : {}), ...(held ? { held } : {}), ...(this.d.roles ? { roles: this.d.roles } : {}) };
|
|
512
|
+
return { ...(this.d.capabilities ? { capabilities: this.d.capabilities(), repository: this.d.repository } : {}), pullOnly: [...this.d.bus.peers.values()].filter(peer => peer.id === "kimi" && peer.pullOnly).map(peer => peer.id), offRoster: SUPPORTED_PEERS.filter(peer => !this.d.bus.isRostered(peer)), ...(failing ? { failing } : {}), ...(held ? { held } : {}), ...(this.d.roles ? { roles: this.d.roles } : {}) };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Capability admission precedes explicit assignments/reservations, independent of readiness or transient peer health. */
|
|
516
|
+
private requireCapabilities(task: Parameters<typeof assign>[0], peer: PeerId, role: TaskAssignmentRole = "owner", preEffect = true): void {
|
|
517
|
+
if (!this.d.capabilities || this.isPii(task)) return;
|
|
518
|
+
const { state: _state, ...draft } = task; // backlog/readiness must not bypass an explicit reservation's admission
|
|
519
|
+
const admission = assign(draft, this.states(), this.d.routing(), { candidates: role === "owner" ? [peer] : [], ...(role === "owner" ? { namedOwner: peer } : { namedReviewer: peer }), ...this.weights(task.class) });
|
|
520
|
+
const missing = (admission.refusals ?? []).filter(row => row.peer === peer && row.role === role && (row.code === "tests" || row.code === "spec")).map(row => ` ${role} candidate ${peer}: skipped, ${row.reason}`);
|
|
521
|
+
if (missing.length) throw new (preEffect ? PreEffectToolRefusal : Error)(`cannot assign ${peer}: ${missing.join("; ")}`);
|
|
474
522
|
}
|
|
475
523
|
|
|
476
524
|
/** What assignment weighs besides states and policy: quota, demotion and the review record. */
|
|
@@ -606,7 +654,9 @@ export class Tasks {
|
|
|
606
654
|
}
|
|
607
655
|
|
|
608
656
|
async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }): Promise<Task> {
|
|
609
|
-
|
|
657
|
+
// #455: one authority check, made before the screen and again after it (a person may move the planner meanwhile).
|
|
658
|
+
const authorized = () => this.planningAuthority(by);
|
|
659
|
+
authorized();
|
|
610
660
|
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");
|
|
611
661
|
const validate = () => {
|
|
612
662
|
const task = this.d.board.get(input.id);
|
|
@@ -616,7 +666,7 @@ export class Tasks {
|
|
|
616
666
|
if (this.waitsFor(task).length) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
|
|
617
667
|
const priority = task.priority ?? (task.signals.includes("urgent") ? 0 : 2);
|
|
618
668
|
if (priority === 0) {
|
|
619
|
-
const goals = this.d.board.list().filter(goal => goal.state
|
|
669
|
+
const goals = this.d.board.list().filter(goal => !isClosedTaskState(goal.state) && (goal.priority ?? (goal.signals.includes("urgent") ? 0 : 2)) === 0 && goal.rank != null && goal.history.some(entry => entry.by === USER && entry.planning?.fields.includes("rank")));
|
|
620
670
|
const effectiveOrder = (candidate: Task) => goals.filter(goal => goal.id === candidate.id || this.prerequisiteOf(goal, candidate.id)).map(goal => ({ rank: goal.rank!, id: goal.id })).sort((a,b) => a.rank-b.rank || a.id-b.id)[0];
|
|
621
671
|
const order = effectiveOrder(task) ?? { rank: task.rank ?? Number.MAX_SAFE_INTEGER, id: task.id };
|
|
622
672
|
if (this.d.board.list("backlog").some(other => {
|
|
@@ -631,6 +681,7 @@ export class Tasks {
|
|
|
631
681
|
return { task, owner: assignment.owner };
|
|
632
682
|
};
|
|
633
683
|
const initial = validate(), withheld = await this.withholds("note", input.reason, initial.task);
|
|
684
|
+
authorized();
|
|
634
685
|
const current = validate();
|
|
635
686
|
const pulled = this.d.board.pullPlanned(current.task.id, by, current.owner, input.revision, input.reason.trim(), withheld);
|
|
636
687
|
this.planningRecorded({ by, task: pulled.id, fields: ["pull", "reserved"], revision: pulled.planningRevision! });
|
|
@@ -742,7 +793,7 @@ export class Tasks {
|
|
|
742
793
|
const validate = () => {
|
|
743
794
|
const task = this.d.board.get(input.id);
|
|
744
795
|
if (!task || (task.planningRevision ?? 0) !== input.revision) throw new PreEffectToolRefusal("planning revision changed");
|
|
745
|
-
if (task.state
|
|
796
|
+
if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("closed work is final");
|
|
746
797
|
if (by !== USER && (task.state !== "backlog" || taskHasStarted(task) || task.pinned || Object.hasOwn(patch, "pinned"))) throw new PreEffectToolRefusal("planner changes only unpinned backlog work");
|
|
747
798
|
if (by !== USER && patch.rank !== undefined && task.history.some(entry => entry.by === USER && entry.planning?.fields.includes("rank"))) throw new PreEffectToolRefusal("a person's backlog order is retained");
|
|
748
799
|
if (by !== USER && Object.hasOwn(patch, "reserved") && this.hasPersonReservation(task)) throw new PreEffectToolRefusal("a person's reservation is retained");
|
|
@@ -750,6 +801,7 @@ export class Tasks {
|
|
|
750
801
|
if (task.state !== "backlog" || taskHasStarted(task) || task.owner) throw new PreEffectToolRefusal("dependency and reservation changes require unstarted backlog work");
|
|
751
802
|
if (patch.reserved && !(SUPPORTED_PEERS as readonly string[]).includes(patch.reserved) && !this.d.bus.knownPeers().includes(patch.reserved) && !this.d.routing().classes[task.class]?.peers.includes(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is not a supported or configured peer");
|
|
752
803
|
if (patch.reserved && !this.d.bus.isRostered(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is off roster");
|
|
804
|
+
if (patch.reserved) this.requireCapabilities(task, patch.reserved);
|
|
753
805
|
const rows = new Map(this.d.board.list().map(row => [row.id, row]));
|
|
754
806
|
const pending = [...(patch.deps ?? task.deps ?? [])], seen = new Set<number>();
|
|
755
807
|
while (pending.length) {
|
|
@@ -773,6 +825,7 @@ export class Tasks {
|
|
|
773
825
|
return task;
|
|
774
826
|
};
|
|
775
827
|
const task = validate(), withheld = await this.withholds("note", input.reason, task);
|
|
828
|
+
this.planningAuthority(by); // #455: or moved the planner
|
|
776
829
|
validate(); // a person may have changed planning or frozen a release while the screen ran
|
|
777
830
|
const updated = this.d.board.plan(task.id, by, patch, input.revision, input.reason.trim(), withheld);
|
|
778
831
|
this.planningRecorded({ by, task: task.id, fields: Object.keys(patch), revision: updated.planningRevision! });
|
|
@@ -886,6 +939,7 @@ export class Tasks {
|
|
|
886
939
|
let screened: Awaited<typeof attempt>;
|
|
887
940
|
try { screened = await Promise.race([attempt, new Promise<never>((_, reject) => { deadline = setTimeout(() => reject(new PreEffectToolRefusal("split screening request deadline exceeded")), BACKLOG_SPLIT_SCREEN_TIMEOUT_MS); })]); }
|
|
888
941
|
finally { if (deadline !== undefined) clearTimeout(deadline); }
|
|
942
|
+
this.planningAuthority(by); // #455: a person may have moved the planner while the screen ran
|
|
889
943
|
const [screenedVerdicts, withheld] = screened, current = validate();
|
|
890
944
|
const finalSignals = current.drafts.map(part => this.signalsFor({ ...part, detail: [part.detail, planText(part.plan)].join("\n") }));
|
|
891
945
|
const deterministicPrivate = finalSignals.map((signals, index) => initialPrivate[index] || this.isPii({ signals }));
|
|
@@ -893,6 +947,10 @@ export class Tasks {
|
|
|
893
947
|
const inherited = current.drafts.map((_part, index) => (initialParentPrivate || this.isPii(current.parent)) && !deterministicPrivate[index]);
|
|
894
948
|
const drafts = current.drafts.map((part, index) => ({ ...part, ...(screening ? { screeningNote: inherited[index] ? "pii: inherited" : this.screenNote(verdicts[index]) } : {}), signals: [...new Set([...initialPrivacy[index]!, ...initialParentPrivacy, ...finalSignals[index]!, ...current.parent.signals.filter(signal => ["pii", "pii_path", "urgent"].includes(signal)), ...(verdicts[index] && verdicts[index]!.label !== "clear" ? ["pii"] : [])])] }));
|
|
895
949
|
const result = this.d.board.splitBacklog(id, by, revision, drafts, reason, withheld || initialReasonPrivate || this.isPii(current.parent) || !this.nameable(reason));
|
|
950
|
+
if (this.d.coordination) {
|
|
951
|
+
const mode = this.d.coordination();
|
|
952
|
+
for (const child of result.children) this.coordinationModes.set(child.id, mode);
|
|
953
|
+
}
|
|
896
954
|
for (const [index, child] of result.children.entries()) {
|
|
897
955
|
this.incomingBacklog.add(child.id);
|
|
898
956
|
if (screening) { this.recordScreen(child.id, "task", verdicts[index], inherited[index]); if (verdicts[index]?.label === "unknown") this.releaseNotice(`task #${child.id}: the PII screen gave no verdict (${verdicts[index]!.miss}); it is handled as a PII task`); }
|
|
@@ -906,6 +964,7 @@ export class Tasks {
|
|
|
906
964
|
if (typeof input.name !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(input.name) || typeof input.goal !== "string" || !input.goal.trim() || input.goal.length > 1000 || !Number.isSafeInteger(input.order) || input.order < 0) throw new PreEffectToolRefusal("invalid release name, goal or order");
|
|
907
965
|
const text = input.name + "\n" + input.goal;
|
|
908
966
|
if (!this.nameable(text) || this.screenOn() && (await this.screenCall(text)).label !== "clear") throw new PreEffectToolRefusal("release text was not cleared for public planning");
|
|
967
|
+
this.planningAuthority(by); // #455: a person may have moved the planner while the screen ran
|
|
909
968
|
if (this.d.board.releases().some(release => release.name === input.name)) throw new PreEffectToolRefusal("release name already exists");
|
|
910
969
|
const release = this.d.board.newRelease(input.name, input.goal.trim(), input.order);
|
|
911
970
|
this.planningRecorded({ by, release: release.id, fields: ["created"], revision: release.revision });
|
|
@@ -918,7 +977,7 @@ export class Tasks {
|
|
|
918
977
|
if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || !release || release.revision !== input.revision) throw new PreEffectToolRefusal("release revision changed");
|
|
919
978
|
if (["shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("settled release is final");
|
|
920
979
|
if (by !== USER && (release.state === "frozen" || input.state === "frozen" || input.state === "shipped" || input.state === "dropped")) throw new PreEffectToolRefusal("freeze, ship, drop and frozen changes require a person");
|
|
921
|
-
if (input.state === "shipped" && this.d.board.list().some(task => task.release === release.id && task.state
|
|
980
|
+
if (input.state === "shipped" && this.d.board.list().some(task => task.release === release.id && !isClosedTaskState(task.state))) throw new PreEffectToolRefusal("every release task must be approved, cancelled, or moved out before ship");
|
|
922
981
|
const { id, revision, ...patch } = input;
|
|
923
982
|
if (!Object.keys(patch).length || Object.keys(patch).some(key => !["state", "plannedStart", "cut", "ship"].includes(key))) throw new PreEffectToolRefusal("unknown or empty release fields");
|
|
924
983
|
const updated = this.d.board.updateRelease(id, patch, revision);
|
|
@@ -985,7 +1044,9 @@ export class Tasks {
|
|
|
985
1044
|
catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
|
|
986
1045
|
const draft = { ...text, class: cls, ...(selected ? { role: selected.id } : {}), ...(profile !== "dev" || given !== undefined && given !== cls || this.d.profileData && this.d.profileData !== projectProfile("dev") ? { profile, profileClass: given ?? cls, ...(profile === "dev" ? { customProfile: true as const } : {}) } : {}) };
|
|
987
1046
|
if (external) { const prior = this.d.board.imported(external.repo, external.issue); if (prior) return prior; }
|
|
1047
|
+
if (reserved) this.requireCapabilities({ ...draft, signals, ...(external ? { external } : {}) }, reserved);
|
|
988
1048
|
let task = this.d.board.propose(by, { ...draft, plan, ...(external ? { external } : {}), ...(deps.length ? { deps } : {}), ...(reserved ? { reserved } : {}), signals, ...(backlog ? { backlog: true } : {}) });
|
|
1049
|
+
if (this.d.coordination) this.coordinationModes.set(task.id, this.d.coordination());
|
|
989
1050
|
created?.();
|
|
990
1051
|
if (screening) task = this.screenedTask(task, screened);
|
|
991
1052
|
if (goal) this.linkNative(task, goal, by);
|
|
@@ -1180,7 +1241,7 @@ export class Tasks {
|
|
|
1180
1241
|
* #106): open work by default; the conflict notices of #91 count a task in review too.
|
|
1181
1242
|
*/
|
|
1182
1243
|
whileOpen(to: PeerId, task: number, body: string, states: Task["state"][] = OPEN): void {
|
|
1183
|
-
const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(task) } });
|
|
1244
|
+
const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(task), taskRecipient: "owner" } });
|
|
1184
1245
|
this.conditional.set(env.id, { peer: to, task, states });
|
|
1185
1246
|
if (this.conditional.size > 1024) this.conditional.delete(this.conditional.keys().next().value as string);
|
|
1186
1247
|
this.d.bus.publish(env);
|
|
@@ -1201,6 +1262,10 @@ export class Tasks {
|
|
|
1201
1262
|
}
|
|
1202
1263
|
return true;
|
|
1203
1264
|
}
|
|
1265
|
+
if (env.from === HUB && env.refs?.task) {
|
|
1266
|
+
const task = this.d.board.get(Number(env.refs.task));
|
|
1267
|
+
if (task?.state === "cancelled") return env.kind === "task" && typeof env.refs.taskCancelled === "number" && env.refs.taskCancelled === task.cancelled?.flowRevision;
|
|
1268
|
+
}
|
|
1204
1269
|
const c = this.conditional.get(env.id);
|
|
1205
1270
|
if (!c || c.peer !== peer) return true;
|
|
1206
1271
|
const t = this.d.board.get(c.task);
|
|
@@ -1326,6 +1391,7 @@ export class Tasks {
|
|
|
1326
1391
|
if (typeof target === "number") {
|
|
1327
1392
|
const task = this.d.board.get(target);
|
|
1328
1393
|
if (!task) throw new Error(`no task #${target}`);
|
|
1394
|
+
if (isClosedTaskState(task.state)) return [...workflow, `task ${this.publicTitle(task)} (${task.state}): closed; not routed`];
|
|
1329
1395
|
const a = assign(task, this.states(), routing, { exclude: this.excluded(task), excludeOwners: this.plannerPullRecord(task)?.pull ? this.plannerFull(task) : [], waitsFor: this.waitsFor(task), ...this.weights(task.class) });
|
|
1330
1396
|
// The split trace is for the pair the record is about: the task's owner when it has one (issue #109).
|
|
1331
1397
|
return [...workflow, `task ${this.publicTitle(task)} (${task.state}, owner ${task.owner ?? "none"}${task.reserved ? `, reserved for ${task.reserved}` : ""})`, ...(task.profileClass ? [`profile class ${profileTaskLabel(task)}`] : []), ...(task.role ? [`task role ${task.role} (guidance only)`] : []), "if it were assigned now:", ...a.trace, ...(this.splitShadow(task, task.owner ?? a.owner)?.trace ?? [])];
|
|
@@ -1333,14 +1399,17 @@ export class Tasks {
|
|
|
1333
1399
|
const draft = { title: target.title, detail: target.detail ?? "", refs: target.refs ?? {} };
|
|
1334
1400
|
const signals = this.signalsFor(draft, routing);
|
|
1335
1401
|
const base = profileClass(target.class, this.d.profileData ?? this.d.profile);
|
|
1336
|
-
const trace = [...(this.d.profile && this.d.profile !== "dev" ? [`profile class ${target.class} (base ${base}, ${this.d.profile})`] : []), ...assign({ class: base, signals }, this.states(), routing, this.weights(base)).trace];
|
|
1402
|
+
const trace = [...(this.d.profile && this.d.profile !== "dev" ? [`profile class ${target.class} (base ${base}, ${this.d.profile})`] : []), ...assign({ class: base, signals, ...draft }, this.states(), routing, this.weights(base)).trace];
|
|
1337
1403
|
// A draft is not sent to the PII screen (issue #198): a proposal is, and a pii or unknown verdict adds the pii signal.
|
|
1338
1404
|
return [...workflow, ...(this.screenOn() && !signals.includes("pii") ? [...trace, "pii_screen is on: at proposal a pii or unknown verdict adds signal pii (local only)"] : trace)];
|
|
1339
1405
|
}
|
|
1340
1406
|
|
|
1341
1407
|
/** Public read path: every generated line is screened, including profile/config-derived trace fields. */
|
|
1342
1408
|
publicExplain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }, routing?: Routing): string[] {
|
|
1343
|
-
|
|
1409
|
+
const trace = this.explain(target, routing);
|
|
1410
|
+
const holds = Object.entries(this.d.held?.() ?? {}).flatMap(([peer, hold]) =>
|
|
1411
|
+
trace.some(line => line.startsWith(` hold: ${peer}'s queue is held:`)) ? [] : [`${peer} queue: ${hold}`]);
|
|
1412
|
+
return [...trace, ...holds].map(line => this.nameable(line) ? line : "[routing explanation withheld: it matches a PII pattern]");
|
|
1344
1413
|
}
|
|
1345
1414
|
|
|
1346
1415
|
/**
|
|
@@ -1412,6 +1481,7 @@ export class Tasks {
|
|
|
1412
1481
|
}
|
|
1413
1482
|
const passed = this.passedOver(task, a);
|
|
1414
1483
|
if (!a.owner) {
|
|
1484
|
+
if (!task.owner && task.state === "proposed") this.reportStall(task, this.ownerStallCause(task, a));
|
|
1415
1485
|
this.releaseNotice(opts.roster
|
|
1416
1486
|
? `task ${this.publicTitle(task)}: ${task.owner} is off roster and no peer can take it; it stays with ${task.owner} in ${task.state}; assign with: ahub task assign ${task.id} <peer>`
|
|
1417
1487
|
: `task ${this.publicTitle(task)}: no peer can take it (${a.trace.filter((l) => l.includes("skipped")).length} skipped); assign with: ahub task assign ${task.id} <peer>`);
|
|
@@ -1483,7 +1553,7 @@ export class Tasks {
|
|
|
1483
1553
|
if (by !== USER) throw new PreEffectToolRefusal("only the person may change an existing task catalog role");
|
|
1484
1554
|
if (raw === undefined) throw new PreEffectToolRefusal("name a catalog role id or explicit null to clear it");
|
|
1485
1555
|
const task = this.need(id, false, true);
|
|
1486
|
-
if (task.state
|
|
1556
|
+
if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("a closed task's role is historical; propose new work instead");
|
|
1487
1557
|
let selected;
|
|
1488
1558
|
try { selected = taskRole(raw === null ? undefined : raw, task.class, this.d.catalog ?? loadRoleCatalog(this.d.cwd)); }
|
|
1489
1559
|
catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
|
|
@@ -1493,7 +1563,7 @@ export class Tasks {
|
|
|
1493
1563
|
if (!peer || peer === USER) continue;
|
|
1494
1564
|
this.releaseNotice(`task ${this.publicTitle(next)}: catalog role update delivery unconfirmed`, () => this.d.bus.publish(newEnvelope(HUB,
|
|
1495
1565
|
`Task #${next.id}: catalog role changed by the person. ${next.role ? this.roleGuidance(next, peer, peer === next.reviewer) : "Catalog guidance cleared; follow the task's existing plan and requirements."}`,
|
|
1496
|
-
{ to: [peer], kind: "task", refs: { task: String(next.id) }, ...(this.isPii(next) ? { private: true } : {}) })));
|
|
1566
|
+
{ to: [peer], kind: "task", refs: { task: String(next.id), taskRecipient: peer === next.reviewer ? "reviewer" : "owner" }, ...(this.isPii(next) ? { private: true } : {}) })));
|
|
1497
1567
|
}
|
|
1498
1568
|
return next;
|
|
1499
1569
|
}
|
|
@@ -1548,7 +1618,8 @@ export class Tasks {
|
|
|
1548
1618
|
// (Codex, Pi); held, it starts no turn yet: neither is taking it.
|
|
1549
1619
|
const owner = task.owner!;
|
|
1550
1620
|
const idle = this.d.bus.stateOf(owner) === "idle";
|
|
1551
|
-
const env = newEnvelope(HUB, body, { to: [owner], kind: "task", priority: "important", refs: { ...task.refs, task: String(task.id) }, ...(pii ? { private: true } : {}) });
|
|
1621
|
+
const env = newEnvelope(HUB, body, { to: [owner], kind: "task", priority: "important", refs: { ...task.refs, task: String(task.id), taskRecipient: "owner", taskOffer: task.history.length }, ...(pii ? { private: true } : {}) });
|
|
1622
|
+
this.d.board.recordTaskOffer(task.id, { envelopeId: env.id, owner, assignmentFlowRevision: task.history.length, ...(this.d.offerPullOnly ? { pullOnly: this.d.offerPullOnly(owner) } : {}) }, task.history.length);
|
|
1552
1623
|
this.d.bus.publish(env);
|
|
1553
1624
|
if (idle && this.d.bus.stateOf(owner) === "busy" && !this.d.bus.queueIds(owner).includes(env.id)) this.sent.set(handOver(task, owner), owner);
|
|
1554
1625
|
}
|
|
@@ -1606,14 +1677,98 @@ export class Tasks {
|
|
|
1606
1677
|
* each such task is offered once an attached peer can take it (peers attach one by one). Only an assignment uses
|
|
1607
1678
|
* up its offer for this hub run; a failed or ownerless offer stays eligible, after approval and on the timer alike.
|
|
1608
1679
|
*/
|
|
1680
|
+
private offerCandidates?: { revision: number; offers: { task: number; peer: PeerId; envelopeId: string; assignmentFlowRevision: number; pullOnly?: boolean }[] };
|
|
1681
|
+
private stallCandidates?: { revision: number; rows: { stall: TaskStall; task: Task; planned: boolean }[] };
|
|
1682
|
+
private stallView?: { key: string; stalls: TaskStall[] };
|
|
1683
|
+
|
|
1684
|
+
pendingOffers(): { task: number; peer: PeerId; envelopeId: string; assignmentFlowRevision: number; pullOnly?: boolean }[] {
|
|
1685
|
+
const revision = this.d.board.statusRevision();
|
|
1686
|
+
if (this.offerCandidates?.revision !== revision) this.offerCandidates = { revision, offers: this.d.board.list("proposed").flatMap(task => {
|
|
1687
|
+
const offer = task.offer;
|
|
1688
|
+
if (!offer || task.owner !== offer.owner || task.history.slice(offer.assignmentFlowRevision).some(h => ["accepted", "declined", "assigned", "reassigned", "escalated", "unassigned", "reserved", "backlog returned"].includes(h.event) || h.owner !== undefined && h.owner !== offer.owner)) return [];
|
|
1689
|
+
return [{ task: task.id, peer: offer.owner, envelopeId: offer.envelopeId, assignmentFlowRevision: offer.assignmentFlowRevision, ...(offer.pullOnly === undefined ? {} : { pullOnly: offer.pullOnly }) }];
|
|
1690
|
+
}) };
|
|
1691
|
+
return this.offerCandidates.offers.filter(offer => {
|
|
1692
|
+
try { return this.d.offerRead?.(offer.peer, offer.envelopeId) !== true; } catch { return true; }
|
|
1693
|
+
}).map(offer => ({ ...offer }));
|
|
1694
|
+
}
|
|
1695
|
+
stalled(): TaskStall[] {
|
|
1696
|
+
const revision = this.d.board.statusRevision();
|
|
1697
|
+
if (this.stallCandidates?.revision !== revision) this.stallCandidates = { revision, rows: this.d.board.stalls().flatMap(stall => {
|
|
1698
|
+
const task = this.d.board.get(stall.task);
|
|
1699
|
+
if (task?.state !== "proposed" || task.owner || (task.planningRevision ?? 0) !== stall.replanRevision) return [];
|
|
1700
|
+
if (stall.cause.kind === "cancelled-dependency") return task.deps?.includes(stall.cause.dependency!) === true && this.d.board.get(stall.cause.dependency!)?.state === "cancelled" ? [{ stall, task, planned: false }] : [];
|
|
1701
|
+
return this.waitsFor(task).length ? [] : [{ stall, task, planned: this.plannerPullRecord(task)?.pull === true }];
|
|
1702
|
+
}) };
|
|
1703
|
+
// Eligibility inputs are live; queue journal writes alone do not re-decode board histories.
|
|
1704
|
+
const key = JSON.stringify([revision, this.states(), this.d.routing(), this.health(), [...this.d.bus.peers.keys()].map(peer => {
|
|
1705
|
+
const queue = this.d.bus.queueSummary(peer);
|
|
1706
|
+
return [peer, this.wipLimit(peer), queue.heldBy, queue.needsReview];
|
|
1707
|
+
})]);
|
|
1708
|
+
if (this.stallView?.key !== key) this.stallView = { key, stalls: this.stallCandidates.rows.filter(({ stall, task, planned }) => stall.cause.kind === "cancelled-dependency" || !(planned ? this.plannerAssignment(task, true) : assign(task, this.states(), this.d.routing(), { exclude: this.excluded(task), ...this.health() })).owner).map(({ stall }) => stall) };
|
|
1709
|
+
return structuredClone(this.stallView.stalls);
|
|
1710
|
+
}
|
|
1711
|
+
private ownerStallCause(task: Task, admission: Assignment): TaskStall["cause"] {
|
|
1712
|
+
const refusal = admission.refusals?.find(row => row.role === "owner" && row.peer === task.reserved);
|
|
1713
|
+
return refusal ? { kind: "reservation", peer: refusal.peer, code: refusal.code } : { kind: "no-eligible-peer", code: "unavailable", refusals: admission.refusals?.filter(row => row.role === "owner").map(({ peer, code }) => ({ peer, code })).sort((a, b) => a.peer.localeCompare(b.peer)) ?? [] };
|
|
1714
|
+
}
|
|
1715
|
+
private reportStall(task: Task, cause: TaskStall["cause"], now = Date.now()): void {
|
|
1716
|
+
const stall: TaskStall = { task: task.id, cause, signature: JSON.stringify(cause), replanRevision: task.planningRevision ?? 0, at: now };
|
|
1717
|
+
if (!this.d.board.recordStall(stall)) return;
|
|
1718
|
+
const text = `Task #${task.id} stalled: ${cause.kind}${cause.peer ? ` ${cause.peer}` : ""}${cause.dependency ? ` #${cause.dependency}` : ""} (${cause.code}); configure the runner or peer, ahub task assign ${task.id} <peer>, re-plan dependencies, or cancel it.`;
|
|
1719
|
+
this.releaseNotice(text);
|
|
1720
|
+
try { this.d.stallNotice?.(stall); } catch { /* advisory never changes task authority */ }
|
|
1721
|
+
// Advice joins the planner's next delivery; it must not manufacture a turn beside a withheld backlog batch.
|
|
1722
|
+
for (const [peer, roles] of Object.entries(this.d.roles ?? {})) if (roles.includes("planner") && this.d.bus.isRostered(peer)) this.releaseNotice(`task #${task.id}: stall notice unavailable`, () => this.d.tell?.(peer, noteLine(HUB, "finding", text)));
|
|
1723
|
+
}
|
|
1724
|
+
async cancel(id: unknown, by: PeerId, input: { reason: string; supersededBy?: string; expectedFlowRevision?: number }): Promise<Task> {
|
|
1725
|
+
if (typeof input.reason !== "string" || !input.reason.trim() || Buffer.byteLength(input.reason) > 4000 || input.supersededBy !== undefined && (typeof input.supersededBy !== "string" || !input.supersededBy.trim() || Buffer.byteLength(input.supersededBy) > 1000)) throw new PreEffectToolRefusal("cancellation needs a bounded reason and supersession reference");
|
|
1726
|
+
const validate = () => {
|
|
1727
|
+
if (this.d.cancellationHeld?.() || this.d.bus.isRecoveryHeld) throw new PreEffectToolRefusal("recovery or shutdown is holding cancellation");
|
|
1728
|
+
const task = this.need(id, false, true, true);
|
|
1729
|
+
this.checkFlowRevision(task, input.expectedFlowRevision, true);
|
|
1730
|
+
if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal(`task #${task.id} is already ${task.state}`);
|
|
1731
|
+
if (by !== USER && this.d.privateTurn?.(by) && !this.isPii(task)) throw new PreEffectToolRefusal("cancellation on ordinary work is unavailable during a private turn");
|
|
1732
|
+
if (by !== USER) {
|
|
1733
|
+
this.planningAuthority(by);
|
|
1734
|
+
const proposer = task.history.find(h => h.event === "proposed" || h.event === "backlogged")?.by;
|
|
1735
|
+
if (proposer !== by || !["backlog", "proposed"].includes(task.state) || taskHasStarted(task)) throw new PreEffectToolRefusal("planner may cancel only its own historically unaccepted backlog or proposed work");
|
|
1736
|
+
}
|
|
1737
|
+
return task;
|
|
1738
|
+
};
|
|
1739
|
+
const before = validate(), revision = before.history.length;
|
|
1740
|
+
const reasonText = [input.reason, input.supersededBy].filter(Boolean).join("\n");
|
|
1741
|
+
const withheld = looksSecret(reasonText) || this.aboutPrivatePath("", reasonText, before) || await this.withholds("note", reasonText, before);
|
|
1742
|
+
const task = validate();
|
|
1743
|
+
if (task.history.length !== revision) throw new PreEffectToolRefusal("task flow revision changed");
|
|
1744
|
+
const cancelled = { reason: input.reason.trim(), ...(input.supersededBy ? { supersededBy: input.supersededBy.trim() } : {}), flowRevision: revision + 1, ...(withheld ? { withheld: true as const } : {}) };
|
|
1745
|
+
const next = this.d.board.update(task.id, by, "cancelled", { state: "cancelled", reserved: null, cancelled }, cancelled.reason, { ...(withheld ? { withheld: true as const } : {}) }, revision);
|
|
1746
|
+
this.releaseNotice(`task #${task.id}: cancellation cohort cleanup unavailable`, () => this.cohorts.leave(task.id));
|
|
1747
|
+
this.offered.delete(task.id);
|
|
1748
|
+
this.releaseNotice(`task #${task.id}: cancellation stall cleanup unavailable`, () => this.d.board.clearStall(task.id));
|
|
1749
|
+
for (const peer of new Set([task.owner, task.reviewer])) if (peer && peer !== USER) this.releaseNotice(`task #${task.id}: cancellation notice unavailable`, () => this.d.bus.publish(newEnvelope(HUB, sanitize(`Task #${task.id} cancelled. Stop working on it. No native turn or working tree was changed.`), { to: [peer], kind: "task", priority: "important", refs: { task: String(task.id), taskCancelled: cancelled.flowRevision }, ...(this.isPii(next) ? { private: true } : {}) })));
|
|
1750
|
+
this.releaseNotice(`task #${task.id}: cancellation dependent advice unavailable`, () => {
|
|
1751
|
+
for (const dependent of this.d.board.list()) if (!isClosedTaskState(dependent.state) && !dependent.owner && dependent.deps?.includes(task.id)) this.releaseNotice(`task #${dependent.id}: cancellation stall advice unavailable`, () => this.reportStall(dependent, { kind: "cancelled-dependency", dependency: task.id, code: "cancelled" }));
|
|
1752
|
+
});
|
|
1753
|
+
return next;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1609
1756
|
async releaseReady(): Promise<void> {
|
|
1610
1757
|
for (const t of this.d.board.list("proposed")) {
|
|
1758
|
+
if (t.owner) { this.d.board.clearStall(t.id); continue; }
|
|
1759
|
+
const cancelledDependency = t.deps?.find(id => this.d.board.get(id)?.state === "cancelled");
|
|
1760
|
+
if (cancelledDependency) { this.reportStall(t, { kind: "cancelled-dependency", dependency: cancelledDependency, code: "cancelled" }); continue; }
|
|
1611
1761
|
const last = t.history.findLast(entry => !QUIET_EVENTS.has(entry.event))?.event, planned = this.plannerPullRecord(t)?.pull === true;
|
|
1612
|
-
if (
|
|
1613
|
-
|
|
1762
|
+
if (this.personUnassigned(t) || this.offered.has(t.id) || this.waitsFor(t).length) continue;
|
|
1763
|
+
const admission = planned ? this.plannerAssignment(t, true) : assign(t, this.states(), this.d.routing(), { exclude: this.excluded(t), ...this.health() });
|
|
1764
|
+
if (!admission.owner) {
|
|
1765
|
+
this.reportStall(t, this.ownerStallCause(t, admission));
|
|
1766
|
+
continue;
|
|
1767
|
+
}
|
|
1768
|
+
this.d.board.clearStall(t.id);
|
|
1769
|
+
if ((!t.deps?.length && !t.history.some(h => h.event === "backlog pulled")) || (!planned && last !== "blocked" && last !== "ready" && last !== "reserved")) continue;
|
|
1614
1770
|
// ponytail: a reserved owner (#207) that attaches after another peer that can take the task is passed over here like
|
|
1615
1771
|
// an offline one; hold reserved tasks for a grace period if restarts show that up.
|
|
1616
|
-
if (!assign(t, this.states(), this.d.routing(), { exclude: this.excluded(t), ...this.health() }).owner) continue; // nobody attached can take it yet
|
|
1617
1772
|
await this.offerReady(t, "nothing left to wait for");
|
|
1618
1773
|
}
|
|
1619
1774
|
}
|
|
@@ -1728,13 +1883,13 @@ export class Tasks {
|
|
|
1728
1883
|
if (config.mode === "advise" && blockers.length) this.d.notify(`task ${this.publicTitle(task)}: SDLC ${transition} advice: ${this.isPii(task) ? "private lifecycle evidence unknown" : blockers.join("; ")}`);
|
|
1729
1884
|
}
|
|
1730
1885
|
|
|
1731
|
-
private need(id: unknown, open = false, preEffect = false): Task {
|
|
1886
|
+
private need(id: unknown, open = false, preEffect = false, allowBacklog = false): Task {
|
|
1732
1887
|
// Callers are models (#70): an id is a whole number or a digit string, never an array that Number() would accept.
|
|
1733
1888
|
const n = typeof id === "number" ? id : typeof id === "string" && /^\s*\d+\s*$/.test(id) ? Number(id) : NaN;
|
|
1734
1889
|
if (!Number.isInteger(n)) throw new (preEffect ? PreEffectToolRefusal : Error)(`id must be a task number, not ${(typeof id === "number" ? String(id) : JSON.stringify(id ?? null)).slice(0, 60)}`);
|
|
1735
1890
|
const task = this.d.board.get(n);
|
|
1736
1891
|
if (!task) throw new (preEffect ? PreEffectToolRefusal : Error)(`no task #${n}`);
|
|
1737
|
-
if (task.state === "backlog") throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${n} is backlog: pull it into To Do first`);
|
|
1892
|
+
if (task.state === "backlog" && !allowBacklog) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${n} is backlog: pull it into To Do first`);
|
|
1738
1893
|
// Handing a task to someone else only makes sense while there is work left on it.
|
|
1739
1894
|
if (open && !OPEN.includes(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: it can no longer change hands`);
|
|
1740
1895
|
return task;
|
|
@@ -1775,8 +1930,10 @@ export class Tasks {
|
|
|
1775
1930
|
*/
|
|
1776
1931
|
private formCohort(task: Task, hits: ReturnType<Tasks["overlapHits"]>): void {
|
|
1777
1932
|
if (!task.owner || this.isPii(task)) return;
|
|
1933
|
+
const coordinate = this.coordinationModes.get(task.id);
|
|
1934
|
+
const coordinated = this.d.coordination ? hits.filter(hit => this.coordinationModes.get(hit.task.id) === coordinate) : hits;
|
|
1778
1935
|
const before = this.cohorts.of(task.id)?.revision;
|
|
1779
|
-
const joined = this.cohorts.join(task,
|
|
1936
|
+
const joined = this.cohorts.join(task, coordinated.map((h) => h.task), ownerGen, handedAt, !this.d.coordination || coordinate === true);
|
|
1780
1937
|
if (!joined) return;
|
|
1781
1938
|
const c = joined.cohort;
|
|
1782
1939
|
if (joined.formed || c.revision !== before) {
|
|
@@ -1837,7 +1994,7 @@ export class Tasks {
|
|
|
1837
1994
|
const valid = (): Task => {
|
|
1838
1995
|
const t = this.need(id, false, true);
|
|
1839
1996
|
this.mine(t, by, "owner", true);
|
|
1840
|
-
if (t.state === "in_review" || t.state
|
|
1997
|
+
if (t.state === "in_review" || isClosedTaskState(t.state)) throw new PreEffectToolRefusal(`task #${t.id} is already ${t.state}`);
|
|
1841
1998
|
this.ready(t, true);
|
|
1842
1999
|
if (this.checking.has(t.id)) {
|
|
1843
2000
|
// The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
|
|
@@ -2013,7 +2170,7 @@ export class Tasks {
|
|
|
2013
2170
|
task = this.need(task.id, false, true);
|
|
2014
2171
|
this.mine(task, by, "owner", true);
|
|
2015
2172
|
// In every mode: another done may have landed during the readback.
|
|
2016
|
-
if (task.state === "in_review" || task.state
|
|
2173
|
+
if (task.state === "in_review" || isClosedTaskState(task.state)) throw new Error(`task #${task.id} is already ${task.state}`);
|
|
2017
2174
|
reviewer = task.reviewer;
|
|
2018
2175
|
}
|
|
2019
2176
|
const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: cleanRefs(refs) }, checkOutput ? `${summary ?? ""}\n${checkOutput}`.trim() : summary, { withheld: withheld || undefined });
|
|
@@ -2219,7 +2376,7 @@ export class Tasks {
|
|
|
2219
2376
|
if (from && task.reviewer && task.reviewer !== USER && this.requestedChanges(task).has(task.reviewer)) this.d.board.recordReview({ implementer: from, reviewer: task.reviewer, class: task.class, kind: "escalated", task: task.id });
|
|
2220
2377
|
const stopFormerOwner = (moved: Task) => {
|
|
2221
2378
|
if (from && moved.owner && moved.owner !== from) this.releaseNotice(`task ${this.publicTitle(moved)}: could not tell ${from} it moved to ${moved.owner}`, () =>
|
|
2222
|
-
this.tell({ ...moved, owner: from }, `Task #${moved.id} moved to ${moved.owner} (${why}). Stop working on it.`, this.isPii(moved)));
|
|
2379
|
+
this.tell({ ...moved, owner: from }, `Task #${moved.id} moved to ${moved.owner} (${why}). Stop working on it.`, this.isPii(moved), "withdrawal"));
|
|
2223
2380
|
};
|
|
2224
2381
|
// Only our landed move authorizes a success notice and decision note. The former owner's stop
|
|
2225
2382
|
// message still names the current owner when a later assignment superseded this move.
|
|
@@ -2264,7 +2421,8 @@ export class Tasks {
|
|
|
2264
2421
|
? `Task ${this.publicTitle(next)} is no longer assigned to you${next.owner ? `; its owner is now ${next.owner}` : "; it is unassigned"}. Do not accept or complete it; late calls under your former assignment are refused.`
|
|
2265
2422
|
: `Review request for task #${previous.id} ${this.publicTitle(next)} is withdrawn: ${next.reviewer ? `its reviewer is now ${next.reviewer}` : "the task has no reviewer"}. Do not review this task; a late verdict from the previous reviewer is refused.`;
|
|
2266
2423
|
if (peer === USER) this.d.notify(body);
|
|
2267
|
-
|
|
2424
|
+
// An untagged task notice remains needed by a former role until approval; kind review would look moved already.
|
|
2425
|
+
else this.d.bus.publish(newEnvelope(HUB, body, { to: [peer], kind: "task", priority: "important", refs: { task: String(previous.id) }, ...(this.isPii(next) ? { private: true } : {}) }));
|
|
2268
2426
|
});
|
|
2269
2427
|
}
|
|
2270
2428
|
|
|
@@ -2278,6 +2436,7 @@ export class Tasks {
|
|
|
2278
2436
|
const task = this.need(id, role === "owner", preEffect);
|
|
2279
2437
|
this.checkFlowRevision(task, expectedFlowRevision, preEffect);
|
|
2280
2438
|
if (!this.d.bus.isRostered(peer)) throw new (preEffect ? PreEffectToolRefusal : Error)(`${peer} is off roster; add it to the roster before assigning work`);
|
|
2439
|
+
this.requireCapabilities(task, peer, role, preEffect);
|
|
2281
2440
|
if (role === "reviewer") {
|
|
2282
2441
|
if (!OPEN.includes(task.state) && task.state !== "in_review") throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its reviewer can no longer change`);
|
|
2283
2442
|
this.ready(task, preEffect);
|
|
@@ -2303,7 +2462,7 @@ export class Tasks {
|
|
|
2303
2462
|
if (role !== "owner" && role !== "reviewer") throw new (preEffect ? PreEffectToolRefusal : Error)("role must be owner or reviewer");
|
|
2304
2463
|
const task = this.need(id, false, preEffect);
|
|
2305
2464
|
this.checkFlowRevision(task, expectedFlowRevision, preEffect);
|
|
2306
|
-
if (task.state
|
|
2465
|
+
if (isClosedTaskState(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its ${role} is historical`);
|
|
2307
2466
|
if (role === "owner") {
|
|
2308
2467
|
if (!OPEN.includes(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its owner cannot be unassigned`);
|
|
2309
2468
|
if (!task.owner && !task.reserved) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: no owner or owner reservation to unassign`);
|
|
@@ -2331,7 +2490,7 @@ export class Tasks {
|
|
|
2331
2490
|
/** Read-only preview using the same assignment constraints as the roster relay. */
|
|
2332
2491
|
rosterPreview(peer: PeerId): { id: number; title: string; role: "owner" | "reviewer"; to: PeerId | null }[] {
|
|
2333
2492
|
const routing = this.d.routing();
|
|
2334
|
-
return this.d.board.list().filter(task => task.state
|
|
2493
|
+
return this.d.board.list().filter(task => !isClosedTaskState(task.state) && (task.owner === peer || task.reviewer === peer)).map(task => {
|
|
2335
2494
|
const owner = task.owner === peer;
|
|
2336
2495
|
const weights = this.weights(task.class);
|
|
2337
2496
|
const pii = this.isPii(task);
|
|
@@ -2377,7 +2536,7 @@ export class Tasks {
|
|
|
2377
2536
|
if (next.state === "in_review" && next.reviewer && next.reviewer !== task.reviewer && next.reviewer !== USER) this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${next.reviewer}`, () => this.sendReview(next, next.reviewer!, `(${peer} owned this and is off roster.)`));
|
|
2378
2537
|
}
|
|
2379
2538
|
moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
|
|
2380
|
-
} else if (task.reviewer === peer && task.state
|
|
2539
|
+
} else if (task.reviewer === peer && !isClosedTaskState(task.state)) {
|
|
2381
2540
|
const a = assign(task, this.states(), routing, { exclude: [peer], candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
|
|
2382
2541
|
const reviewer = a.reviewer && a.reviewer !== peer && a.reviewer !== task.owner ? a.reviewer : null;
|
|
2383
2542
|
if (roster && !reviewer) {
|
|
@@ -2529,7 +2688,7 @@ export class Tasks {
|
|
|
2529
2688
|
let n = 0;
|
|
2530
2689
|
for (const t of this.d.board.list("approved")) {
|
|
2531
2690
|
if (this.isPii(t)) continue;
|
|
2532
|
-
try { this.d.memoryCheck?.(); const key = this.memoryKey(t); if (this.d.store.store.hasTask(key) && !this.d.store.store.settled(key)) { this.settleMemory(t); n++; } }
|
|
2691
|
+
try { this.d.memoryCheck?.(); const key = this.memoryKey(t); if (this.d.store.store.hasTask(key) && !this.d.store.store.settled(key, { includeLegacy: true })) { this.settleMemory(t); n++; } }
|
|
2533
2692
|
catch { /* fail-open: tried again at the next start */ }
|
|
2534
2693
|
}
|
|
2535
2694
|
return n;
|
|
@@ -2570,9 +2729,9 @@ export class Tasks {
|
|
|
2570
2729
|
}
|
|
2571
2730
|
}
|
|
2572
2731
|
|
|
2573
|
-
private tell(task: Task, body: string, pii: boolean): void {
|
|
2732
|
+
private tell(task: Task, body: string, pii: boolean, audience: "owner" | "withdrawal" = "owner"): void {
|
|
2574
2733
|
if (!task.owner || task.owner === USER) return;
|
|
2575
|
-
this.d.bus.publish(newEnvelope(HUB, body, { to: [task.owner], kind: "task", priority: "important", refs: { task: String(task.id) }, ...(pii ? { private: true } : {}) }));
|
|
2734
|
+
this.d.bus.publish(newEnvelope(HUB, body, { to: [task.owner], kind: "task", priority: "important", refs: { task: String(task.id), ...(audience === "owner" ? { taskRecipient: "owner" as const } : {}) }, ...(pii ? { private: true } : {}) }));
|
|
2576
2735
|
}
|
|
2577
2736
|
}
|
|
2578
2737
|
|
package/src/hub/ui.ts
CHANGED
|
@@ -131,7 +131,7 @@ export function startDashboard(options: DashboardOptions) {
|
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
133
|
try {
|
|
134
|
-
if (sessionOf(cookie).liveTest && !["start", "stop", "pause", "resume", "permit", "start_peer", "setting", "setting_undo", "setting_preview", "sdlc_queue", "sdlc_release"].includes(String(input.action))) {
|
|
134
|
+
if (sessionOf(cookie).liveTest && !["start", "stop", "pause", "resume", "permit", "start_peer", "setting", "setting_undo", "setting_preview", "setting_reload", "sdlc_queue", "sdlc_release"].includes(String(input.action))) {
|
|
135
135
|
return reject(403, "live-test dashboard cannot write person-attributed content; use peer tools");
|
|
136
136
|
}
|
|
137
137
|
if (input.action === "sdlc_queue" || input.action === "sdlc_release") server.timeout(req, 35);
|