@staix/agent-hub 0.12.30 → 0.12.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +21 -7
- package/docs/agent-notes/adapters.md +2 -1
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/bus.md +4 -0
- package/docs/agent-notes/daemon.md +5 -2
- package/docs/agent-notes/local-worker.md +2 -0
- package/docs/agent-notes/tasks.md +5 -0
- package/docs/agent-notes/tests.md +5 -2
- package/docs/memory.md +1 -1
- package/docs/operations.md +129 -22
- package/docs/quickstart.md +17 -2
- package/docs/security.md +30 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +37 -3
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -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/verified.json +13 -13
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +66 -19
- package/src/adapters/claude-channel.ts +29 -11
- package/src/adapters/local-worker.ts +2 -2
- package/src/adapters/pi.ts +5 -0
- package/src/cli/attention.ts +5 -1
- package/src/cli/console-state.ts +56 -12
- package/src/cli/console.ts +19 -6
- package/src/cli/identity.ts +3 -2
- package/src/cli/jcode-mcp.ts +1 -1
- package/src/cli/main.ts +8 -1
- package/src/cli/output.ts +13 -2
- package/src/cli/upgrade-interactive.ts +2 -1
- package/src/cli/upgrade-restore-state.ts +133 -0
- package/src/cli/upgrade-runtime.ts +162 -58
- package/src/cli/upgrade.ts +49 -24
- package/src/hub/board.ts +82 -24
- package/src/hub/bus.ts +78 -29
- package/src/hub/check-runner.ts +514 -0
- package/src/hub/child-process.ts +1 -0
- package/src/hub/control-client.ts +5 -1
- package/src/hub/daemon.ts +400 -67
- 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/host-executable.ts +11 -0
- package/src/hub/hub-tools.ts +9 -1
- package/src/hub/peer-catalog.ts +3 -0
- package/src/hub/routing.ts +35 -16
- package/src/hub/settings.ts +2 -1
- package/src/hub/spec-read.ts +120 -0
- package/src/hub/supervision.ts +21 -6
- package/src/hub/task-sweep.ts +7 -6
- package/src/hub/tasks.ts +173 -34
- package/src/memory/capture.ts +2 -1
- package/src/ui/index.html +12 -9
- 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>;
|
|
@@ -309,8 +315,27 @@ export class Tasks {
|
|
|
309
315
|
try {
|
|
310
316
|
for (const task of this.d.board.list()) {
|
|
311
317
|
if (this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) break;
|
|
312
|
-
|
|
318
|
+
if (!task.owner && this.personUnassigned(task)) continue;
|
|
319
|
+
const finding = nextSweep(task, this.sweepConfig, now, this.waitsFor(task).length === 0);
|
|
313
320
|
if (!finding || this.waitsFor(task).length || this.isChecking(task.id) || this.silentFor(task.id)) continue;
|
|
321
|
+
if (finding.kind === "ready-unassigned") {
|
|
322
|
+
this.d.board.update(task.id, HUB, "idle sweep", {}, undefined, { sweep: finding });
|
|
323
|
+
recorded.push({ task: task.id, finding });
|
|
324
|
+
const planners = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => roles.includes("planner") && this.d.bus.isRostered(peer)).map(([peer]) => peer);
|
|
325
|
+
const text = `Task #${task.id}: ready-unassigned, idle escalation ${finding.step}/3.`;
|
|
326
|
+
if (finding.step === 1 || finding.step === 2) {
|
|
327
|
+
if (finding.step === 2 || !planners.length) this.releaseNotice(text);
|
|
328
|
+
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"));
|
|
329
|
+
} else {
|
|
330
|
+
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) });
|
|
331
|
+
this.releaseNotice(`${text} Owner reassignment suggestion: ${admission.owner ?? "no eligible peer"}.`);
|
|
332
|
+
if (admission.owner && this.sweepConfig.auto_reassign && this.sweepAvailable(admission.owner)) {
|
|
333
|
+
try { await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [admission.owner], event: "reassigned", reason: "idle", note: "ready-unassigned idle sweep" }); }
|
|
334
|
+
catch (e) { if (!(e instanceof AssignmentUndeliveredError)) throw e; }
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
314
339
|
const responsible = finding.kind === "review-pending" ? task.reviewer! : task.owner!;
|
|
315
340
|
if (responsible !== USER && !this.sweepAvailable(responsible)) continue;
|
|
316
341
|
if (finding.step === 3 && finding.kind !== "review-pending" && this.sweepConfig.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;
|
|
@@ -321,11 +346,11 @@ export class Tasks {
|
|
|
321
346
|
this.d.board.update(task.id, HUB, "idle sweep", {}, undefined, { sweep: finding });
|
|
322
347
|
recorded.push({ task: task.id, finding });
|
|
323
348
|
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
|
|
349
|
+
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
350
|
} else if (finding.step === 2) {
|
|
326
351
|
this.d.notify(message);
|
|
327
352
|
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
|
|
353
|
+
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
354
|
}
|
|
330
355
|
} else {
|
|
331
356
|
const reviewer = finding.kind === "review-pending";
|
|
@@ -353,9 +378,9 @@ export class Tasks {
|
|
|
353
378
|
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
379
|
}
|
|
355
380
|
|
|
356
|
-
private sweepNotice(task: Task, peer: PeerId, body: string): void {
|
|
381
|
+
private sweepNotice(task: Task, peer: PeerId, body: string, taskRecipient: "owner" | "reviewer" | "planner"): void {
|
|
357
382
|
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) } }));
|
|
383
|
+
else this.d.bus.publish(newEnvelope(HUB, body, { to: [peer], kind: "task", refs: { task: String(task.id), taskRecipient } }));
|
|
359
384
|
}
|
|
360
385
|
|
|
361
386
|
configureExecutionBudget = (config: ExecutionBudgetConfig): ExecutionBudgetStatus | undefined => {
|
|
@@ -387,19 +412,20 @@ export class Tasks {
|
|
|
387
412
|
|
|
388
413
|
/** A task as a cloud peer may see it. */
|
|
389
414
|
publicView(task: Task, fullHistory = false): Record<string, unknown> {
|
|
390
|
-
const { title, detail, history, external, ...rest } = task;
|
|
415
|
+
const { title, detail, history, external, cancelled, ...rest } = task;
|
|
391
416
|
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
417
|
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
418
|
const publicHistory = (fullHistory ? history : history.slice(-5)).map(h => ({
|
|
394
419
|
at: h.at, by: h.by, event: h.event,
|
|
395
420
|
...(h.note === undefined ? {} : { note: screenHistoryText(h.note, "note", h.withheld) }),
|
|
421
|
+
...(h.supersededBy === undefined ? {} : { supersededBy: screenHistoryText(h.supersededBy, "supersession", h.withheld) }),
|
|
396
422
|
...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
|
|
397
423
|
...(h.owner === undefined ? {} : { owner: h.owner }),
|
|
398
424
|
...(h.planning ? { planning: { revision: h.planning.revision, fields: h.planning.fields, ...(h.planning.split ? { children: h.planning.split.children.map(child => child.id) } : {}) } } : {}),
|
|
399
425
|
...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
|
|
400
426
|
...(h.sweep ? { sweep: { kind: h.sweep.kind, activity: h.sweep.activity, step: h.sweep.step, at: h.sweep.at } } : {}),
|
|
401
427
|
}));
|
|
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 };
|
|
428
|
+
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
429
|
}
|
|
404
430
|
|
|
405
431
|
/** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
|
|
@@ -470,7 +496,16 @@ export class Tasks {
|
|
|
470
496
|
private health() {
|
|
471
497
|
const failing = this.d.failing?.();
|
|
472
498
|
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 } : {}) };
|
|
499
|
+
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 } : {}) };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Capability admission precedes explicit assignments/reservations, independent of readiness or transient peer health. */
|
|
503
|
+
private requireCapabilities(task: Parameters<typeof assign>[0], peer: PeerId, role: TaskAssignmentRole = "owner", preEffect = true): void {
|
|
504
|
+
if (!this.d.capabilities || this.isPii(task)) return;
|
|
505
|
+
const { state: _state, ...draft } = task; // backlog/readiness must not bypass an explicit reservation's admission
|
|
506
|
+
const admission = assign(draft, this.states(), this.d.routing(), { candidates: role === "owner" ? [peer] : [], ...(role === "owner" ? { namedOwner: peer } : { namedReviewer: peer }), ...this.weights(task.class) });
|
|
507
|
+
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}`);
|
|
508
|
+
if (missing.length) throw new (preEffect ? PreEffectToolRefusal : Error)(`cannot assign ${peer}: ${missing.join("; ")}`);
|
|
474
509
|
}
|
|
475
510
|
|
|
476
511
|
/** What assignment weighs besides states and policy: quota, demotion and the review record. */
|
|
@@ -606,7 +641,9 @@ export class Tasks {
|
|
|
606
641
|
}
|
|
607
642
|
|
|
608
643
|
async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }): Promise<Task> {
|
|
609
|
-
|
|
644
|
+
// #455: one authority check, made before the screen and again after it (a person may move the planner meanwhile).
|
|
645
|
+
const authorized = () => this.planningAuthority(by);
|
|
646
|
+
authorized();
|
|
610
647
|
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
648
|
const validate = () => {
|
|
612
649
|
const task = this.d.board.get(input.id);
|
|
@@ -616,7 +653,7 @@ export class Tasks {
|
|
|
616
653
|
if (this.waitsFor(task).length) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
|
|
617
654
|
const priority = task.priority ?? (task.signals.includes("urgent") ? 0 : 2);
|
|
618
655
|
if (priority === 0) {
|
|
619
|
-
const goals = this.d.board.list().filter(goal => goal.state
|
|
656
|
+
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
657
|
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
658
|
const order = effectiveOrder(task) ?? { rank: task.rank ?? Number.MAX_SAFE_INTEGER, id: task.id };
|
|
622
659
|
if (this.d.board.list("backlog").some(other => {
|
|
@@ -631,6 +668,7 @@ export class Tasks {
|
|
|
631
668
|
return { task, owner: assignment.owner };
|
|
632
669
|
};
|
|
633
670
|
const initial = validate(), withheld = await this.withholds("note", input.reason, initial.task);
|
|
671
|
+
authorized();
|
|
634
672
|
const current = validate();
|
|
635
673
|
const pulled = this.d.board.pullPlanned(current.task.id, by, current.owner, input.revision, input.reason.trim(), withheld);
|
|
636
674
|
this.planningRecorded({ by, task: pulled.id, fields: ["pull", "reserved"], revision: pulled.planningRevision! });
|
|
@@ -742,7 +780,7 @@ export class Tasks {
|
|
|
742
780
|
const validate = () => {
|
|
743
781
|
const task = this.d.board.get(input.id);
|
|
744
782
|
if (!task || (task.planningRevision ?? 0) !== input.revision) throw new PreEffectToolRefusal("planning revision changed");
|
|
745
|
-
if (task.state
|
|
783
|
+
if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("closed work is final");
|
|
746
784
|
if (by !== USER && (task.state !== "backlog" || taskHasStarted(task) || task.pinned || Object.hasOwn(patch, "pinned"))) throw new PreEffectToolRefusal("planner changes only unpinned backlog work");
|
|
747
785
|
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
786
|
if (by !== USER && Object.hasOwn(patch, "reserved") && this.hasPersonReservation(task)) throw new PreEffectToolRefusal("a person's reservation is retained");
|
|
@@ -750,6 +788,7 @@ export class Tasks {
|
|
|
750
788
|
if (task.state !== "backlog" || taskHasStarted(task) || task.owner) throw new PreEffectToolRefusal("dependency and reservation changes require unstarted backlog work");
|
|
751
789
|
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
790
|
if (patch.reserved && !this.d.bus.isRostered(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is off roster");
|
|
791
|
+
if (patch.reserved) this.requireCapabilities(task, patch.reserved);
|
|
753
792
|
const rows = new Map(this.d.board.list().map(row => [row.id, row]));
|
|
754
793
|
const pending = [...(patch.deps ?? task.deps ?? [])], seen = new Set<number>();
|
|
755
794
|
while (pending.length) {
|
|
@@ -773,6 +812,7 @@ export class Tasks {
|
|
|
773
812
|
return task;
|
|
774
813
|
};
|
|
775
814
|
const task = validate(), withheld = await this.withholds("note", input.reason, task);
|
|
815
|
+
this.planningAuthority(by); // #455: or moved the planner
|
|
776
816
|
validate(); // a person may have changed planning or frozen a release while the screen ran
|
|
777
817
|
const updated = this.d.board.plan(task.id, by, patch, input.revision, input.reason.trim(), withheld);
|
|
778
818
|
this.planningRecorded({ by, task: task.id, fields: Object.keys(patch), revision: updated.planningRevision! });
|
|
@@ -886,6 +926,7 @@ export class Tasks {
|
|
|
886
926
|
let screened: Awaited<typeof attempt>;
|
|
887
927
|
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
928
|
finally { if (deadline !== undefined) clearTimeout(deadline); }
|
|
929
|
+
this.planningAuthority(by); // #455: a person may have moved the planner while the screen ran
|
|
889
930
|
const [screenedVerdicts, withheld] = screened, current = validate();
|
|
890
931
|
const finalSignals = current.drafts.map(part => this.signalsFor({ ...part, detail: [part.detail, planText(part.plan)].join("\n") }));
|
|
891
932
|
const deterministicPrivate = finalSignals.map((signals, index) => initialPrivate[index] || this.isPii({ signals }));
|
|
@@ -906,6 +947,7 @@ export class Tasks {
|
|
|
906
947
|
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
948
|
const text = input.name + "\n" + input.goal;
|
|
908
949
|
if (!this.nameable(text) || this.screenOn() && (await this.screenCall(text)).label !== "clear") throw new PreEffectToolRefusal("release text was not cleared for public planning");
|
|
950
|
+
this.planningAuthority(by); // #455: a person may have moved the planner while the screen ran
|
|
909
951
|
if (this.d.board.releases().some(release => release.name === input.name)) throw new PreEffectToolRefusal("release name already exists");
|
|
910
952
|
const release = this.d.board.newRelease(input.name, input.goal.trim(), input.order);
|
|
911
953
|
this.planningRecorded({ by, release: release.id, fields: ["created"], revision: release.revision });
|
|
@@ -918,7 +960,7 @@ export class Tasks {
|
|
|
918
960
|
if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || !release || release.revision !== input.revision) throw new PreEffectToolRefusal("release revision changed");
|
|
919
961
|
if (["shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("settled release is final");
|
|
920
962
|
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
|
|
963
|
+
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
964
|
const { id, revision, ...patch } = input;
|
|
923
965
|
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
966
|
const updated = this.d.board.updateRelease(id, patch, revision);
|
|
@@ -985,6 +1027,7 @@ export class Tasks {
|
|
|
985
1027
|
catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
|
|
986
1028
|
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
1029
|
if (external) { const prior = this.d.board.imported(external.repo, external.issue); if (prior) return prior; }
|
|
1030
|
+
if (reserved) this.requireCapabilities({ ...draft, signals, ...(external ? { external } : {}) }, reserved);
|
|
988
1031
|
let task = this.d.board.propose(by, { ...draft, plan, ...(external ? { external } : {}), ...(deps.length ? { deps } : {}), ...(reserved ? { reserved } : {}), signals, ...(backlog ? { backlog: true } : {}) });
|
|
989
1032
|
created?.();
|
|
990
1033
|
if (screening) task = this.screenedTask(task, screened);
|
|
@@ -1180,7 +1223,7 @@ export class Tasks {
|
|
|
1180
1223
|
* #106): open work by default; the conflict notices of #91 count a task in review too.
|
|
1181
1224
|
*/
|
|
1182
1225
|
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) } });
|
|
1226
|
+
const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(task), taskRecipient: "owner" } });
|
|
1184
1227
|
this.conditional.set(env.id, { peer: to, task, states });
|
|
1185
1228
|
if (this.conditional.size > 1024) this.conditional.delete(this.conditional.keys().next().value as string);
|
|
1186
1229
|
this.d.bus.publish(env);
|
|
@@ -1201,6 +1244,10 @@ export class Tasks {
|
|
|
1201
1244
|
}
|
|
1202
1245
|
return true;
|
|
1203
1246
|
}
|
|
1247
|
+
if (env.from === HUB && env.refs?.task) {
|
|
1248
|
+
const task = this.d.board.get(Number(env.refs.task));
|
|
1249
|
+
if (task?.state === "cancelled") return env.kind === "task" && typeof env.refs.taskCancelled === "number" && env.refs.taskCancelled === task.cancelled?.flowRevision;
|
|
1250
|
+
}
|
|
1204
1251
|
const c = this.conditional.get(env.id);
|
|
1205
1252
|
if (!c || c.peer !== peer) return true;
|
|
1206
1253
|
const t = this.d.board.get(c.task);
|
|
@@ -1326,6 +1373,7 @@ export class Tasks {
|
|
|
1326
1373
|
if (typeof target === "number") {
|
|
1327
1374
|
const task = this.d.board.get(target);
|
|
1328
1375
|
if (!task) throw new Error(`no task #${target}`);
|
|
1376
|
+
if (isClosedTaskState(task.state)) return [...workflow, `task ${this.publicTitle(task)} (${task.state}): closed; not routed`];
|
|
1329
1377
|
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
1378
|
// The split trace is for the pair the record is about: the task's owner when it has one (issue #109).
|
|
1331
1379
|
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 +1381,17 @@ export class Tasks {
|
|
|
1333
1381
|
const draft = { title: target.title, detail: target.detail ?? "", refs: target.refs ?? {} };
|
|
1334
1382
|
const signals = this.signalsFor(draft, routing);
|
|
1335
1383
|
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];
|
|
1384
|
+
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
1385
|
// 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
1386
|
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
1387
|
}
|
|
1340
1388
|
|
|
1341
1389
|
/** Public read path: every generated line is screened, including profile/config-derived trace fields. */
|
|
1342
1390
|
publicExplain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }, routing?: Routing): string[] {
|
|
1343
|
-
|
|
1391
|
+
const trace = this.explain(target, routing);
|
|
1392
|
+
const holds = Object.entries(this.d.held?.() ?? {}).flatMap(([peer, hold]) =>
|
|
1393
|
+
trace.some(line => line.startsWith(` hold: ${peer}'s queue is held:`)) ? [] : [`${peer} queue: ${hold}`]);
|
|
1394
|
+
return [...trace, ...holds].map(line => this.nameable(line) ? line : "[routing explanation withheld: it matches a PII pattern]");
|
|
1344
1395
|
}
|
|
1345
1396
|
|
|
1346
1397
|
/**
|
|
@@ -1412,6 +1463,7 @@ export class Tasks {
|
|
|
1412
1463
|
}
|
|
1413
1464
|
const passed = this.passedOver(task, a);
|
|
1414
1465
|
if (!a.owner) {
|
|
1466
|
+
if (!task.owner && task.state === "proposed") this.reportStall(task, this.ownerStallCause(task, a));
|
|
1415
1467
|
this.releaseNotice(opts.roster
|
|
1416
1468
|
? `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
1469
|
: `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 +1535,7 @@ export class Tasks {
|
|
|
1483
1535
|
if (by !== USER) throw new PreEffectToolRefusal("only the person may change an existing task catalog role");
|
|
1484
1536
|
if (raw === undefined) throw new PreEffectToolRefusal("name a catalog role id or explicit null to clear it");
|
|
1485
1537
|
const task = this.need(id, false, true);
|
|
1486
|
-
if (task.state
|
|
1538
|
+
if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("a closed task's role is historical; propose new work instead");
|
|
1487
1539
|
let selected;
|
|
1488
1540
|
try { selected = taskRole(raw === null ? undefined : raw, task.class, this.d.catalog ?? loadRoleCatalog(this.d.cwd)); }
|
|
1489
1541
|
catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
|
|
@@ -1493,7 +1545,7 @@ export class Tasks {
|
|
|
1493
1545
|
if (!peer || peer === USER) continue;
|
|
1494
1546
|
this.releaseNotice(`task ${this.publicTitle(next)}: catalog role update delivery unconfirmed`, () => this.d.bus.publish(newEnvelope(HUB,
|
|
1495
1547
|
`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 } : {}) })));
|
|
1548
|
+
{ to: [peer], kind: "task", refs: { task: String(next.id), taskRecipient: peer === next.reviewer ? "reviewer" : "owner" }, ...(this.isPii(next) ? { private: true } : {}) })));
|
|
1497
1549
|
}
|
|
1498
1550
|
return next;
|
|
1499
1551
|
}
|
|
@@ -1548,7 +1600,8 @@ export class Tasks {
|
|
|
1548
1600
|
// (Codex, Pi); held, it starts no turn yet: neither is taking it.
|
|
1549
1601
|
const owner = task.owner!;
|
|
1550
1602
|
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 } : {}) });
|
|
1603
|
+
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 } : {}) });
|
|
1604
|
+
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
1605
|
this.d.bus.publish(env);
|
|
1553
1606
|
if (idle && this.d.bus.stateOf(owner) === "busy" && !this.d.bus.queueIds(owner).includes(env.id)) this.sent.set(handOver(task, owner), owner);
|
|
1554
1607
|
}
|
|
@@ -1606,14 +1659,98 @@ export class Tasks {
|
|
|
1606
1659
|
* each such task is offered once an attached peer can take it (peers attach one by one). Only an assignment uses
|
|
1607
1660
|
* up its offer for this hub run; a failed or ownerless offer stays eligible, after approval and on the timer alike.
|
|
1608
1661
|
*/
|
|
1662
|
+
private offerCandidates?: { revision: number; offers: { task: number; peer: PeerId; envelopeId: string; assignmentFlowRevision: number; pullOnly?: boolean }[] };
|
|
1663
|
+
private stallCandidates?: { revision: number; rows: { stall: TaskStall; task: Task; planned: boolean }[] };
|
|
1664
|
+
private stallView?: { key: string; stalls: TaskStall[] };
|
|
1665
|
+
|
|
1666
|
+
pendingOffers(): { task: number; peer: PeerId; envelopeId: string; assignmentFlowRevision: number; pullOnly?: boolean }[] {
|
|
1667
|
+
const revision = this.d.board.statusRevision();
|
|
1668
|
+
if (this.offerCandidates?.revision !== revision) this.offerCandidates = { revision, offers: this.d.board.list("proposed").flatMap(task => {
|
|
1669
|
+
const offer = task.offer;
|
|
1670
|
+
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 [];
|
|
1671
|
+
return [{ task: task.id, peer: offer.owner, envelopeId: offer.envelopeId, assignmentFlowRevision: offer.assignmentFlowRevision, ...(offer.pullOnly === undefined ? {} : { pullOnly: offer.pullOnly }) }];
|
|
1672
|
+
}) };
|
|
1673
|
+
return this.offerCandidates.offers.filter(offer => {
|
|
1674
|
+
try { return this.d.offerRead?.(offer.peer, offer.envelopeId) !== true; } catch { return true; }
|
|
1675
|
+
}).map(offer => ({ ...offer }));
|
|
1676
|
+
}
|
|
1677
|
+
stalled(): TaskStall[] {
|
|
1678
|
+
const revision = this.d.board.statusRevision();
|
|
1679
|
+
if (this.stallCandidates?.revision !== revision) this.stallCandidates = { revision, rows: this.d.board.stalls().flatMap(stall => {
|
|
1680
|
+
const task = this.d.board.get(stall.task);
|
|
1681
|
+
if (task?.state !== "proposed" || task.owner || (task.planningRevision ?? 0) !== stall.replanRevision) return [];
|
|
1682
|
+
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 }] : [];
|
|
1683
|
+
return this.waitsFor(task).length ? [] : [{ stall, task, planned: this.plannerPullRecord(task)?.pull === true }];
|
|
1684
|
+
}) };
|
|
1685
|
+
// Eligibility inputs are live; queue journal writes alone do not re-decode board histories.
|
|
1686
|
+
const key = JSON.stringify([revision, this.states(), this.d.routing(), this.health(), [...this.d.bus.peers.keys()].map(peer => {
|
|
1687
|
+
const queue = this.d.bus.queueSummary(peer);
|
|
1688
|
+
return [peer, this.wipLimit(peer), queue.heldBy, queue.needsReview];
|
|
1689
|
+
})]);
|
|
1690
|
+
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) };
|
|
1691
|
+
return structuredClone(this.stallView.stalls);
|
|
1692
|
+
}
|
|
1693
|
+
private ownerStallCause(task: Task, admission: Assignment): TaskStall["cause"] {
|
|
1694
|
+
const refusal = admission.refusals?.find(row => row.role === "owner" && row.peer === task.reserved);
|
|
1695
|
+
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)) ?? [] };
|
|
1696
|
+
}
|
|
1697
|
+
private reportStall(task: Task, cause: TaskStall["cause"], now = Date.now()): void {
|
|
1698
|
+
const stall: TaskStall = { task: task.id, cause, signature: JSON.stringify(cause), replanRevision: task.planningRevision ?? 0, at: now };
|
|
1699
|
+
if (!this.d.board.recordStall(stall)) return;
|
|
1700
|
+
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.`;
|
|
1701
|
+
this.releaseNotice(text);
|
|
1702
|
+
try { this.d.stallNotice?.(stall); } catch { /* advisory never changes task authority */ }
|
|
1703
|
+
// Advice joins the planner's next delivery; it must not manufacture a turn beside a withheld backlog batch.
|
|
1704
|
+
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)));
|
|
1705
|
+
}
|
|
1706
|
+
async cancel(id: unknown, by: PeerId, input: { reason: string; supersededBy?: string; expectedFlowRevision?: number }): Promise<Task> {
|
|
1707
|
+
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");
|
|
1708
|
+
const validate = () => {
|
|
1709
|
+
if (this.d.cancellationHeld?.() || this.d.bus.isRecoveryHeld) throw new PreEffectToolRefusal("recovery or shutdown is holding cancellation");
|
|
1710
|
+
const task = this.need(id, false, true, true);
|
|
1711
|
+
this.checkFlowRevision(task, input.expectedFlowRevision, true);
|
|
1712
|
+
if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal(`task #${task.id} is already ${task.state}`);
|
|
1713
|
+
if (by !== USER && this.d.privateTurn?.(by) && !this.isPii(task)) throw new PreEffectToolRefusal("cancellation on ordinary work is unavailable during a private turn");
|
|
1714
|
+
if (by !== USER) {
|
|
1715
|
+
this.planningAuthority(by);
|
|
1716
|
+
const proposer = task.history.find(h => h.event === "proposed" || h.event === "backlogged")?.by;
|
|
1717
|
+
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");
|
|
1718
|
+
}
|
|
1719
|
+
return task;
|
|
1720
|
+
};
|
|
1721
|
+
const before = validate(), revision = before.history.length;
|
|
1722
|
+
const reasonText = [input.reason, input.supersededBy].filter(Boolean).join("\n");
|
|
1723
|
+
const withheld = looksSecret(reasonText) || this.aboutPrivatePath("", reasonText, before) || await this.withholds("note", reasonText, before);
|
|
1724
|
+
const task = validate();
|
|
1725
|
+
if (task.history.length !== revision) throw new PreEffectToolRefusal("task flow revision changed");
|
|
1726
|
+
const cancelled = { reason: input.reason.trim(), ...(input.supersededBy ? { supersededBy: input.supersededBy.trim() } : {}), flowRevision: revision + 1, ...(withheld ? { withheld: true as const } : {}) };
|
|
1727
|
+
const next = this.d.board.update(task.id, by, "cancelled", { state: "cancelled", reserved: null, cancelled }, cancelled.reason, { ...(withheld ? { withheld: true as const } : {}) }, revision);
|
|
1728
|
+
this.releaseNotice(`task #${task.id}: cancellation cohort cleanup unavailable`, () => this.cohorts.leave(task.id));
|
|
1729
|
+
this.offered.delete(task.id);
|
|
1730
|
+
this.releaseNotice(`task #${task.id}: cancellation stall cleanup unavailable`, () => this.d.board.clearStall(task.id));
|
|
1731
|
+
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 } : {}) })));
|
|
1732
|
+
this.releaseNotice(`task #${task.id}: cancellation dependent advice unavailable`, () => {
|
|
1733
|
+
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" }));
|
|
1734
|
+
});
|
|
1735
|
+
return next;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1609
1738
|
async releaseReady(): Promise<void> {
|
|
1610
1739
|
for (const t of this.d.board.list("proposed")) {
|
|
1740
|
+
if (t.owner) { this.d.board.clearStall(t.id); continue; }
|
|
1741
|
+
const cancelledDependency = t.deps?.find(id => this.d.board.get(id)?.state === "cancelled");
|
|
1742
|
+
if (cancelledDependency) { this.reportStall(t, { kind: "cancelled-dependency", dependency: cancelledDependency, code: "cancelled" }); continue; }
|
|
1611
1743
|
const last = t.history.findLast(entry => !QUIET_EVENTS.has(entry.event))?.event, planned = this.plannerPullRecord(t)?.pull === true;
|
|
1612
|
-
if (
|
|
1613
|
-
|
|
1744
|
+
if (this.personUnassigned(t) || this.offered.has(t.id) || this.waitsFor(t).length) continue;
|
|
1745
|
+
const admission = planned ? this.plannerAssignment(t, true) : assign(t, this.states(), this.d.routing(), { exclude: this.excluded(t), ...this.health() });
|
|
1746
|
+
if (!admission.owner) {
|
|
1747
|
+
this.reportStall(t, this.ownerStallCause(t, admission));
|
|
1748
|
+
continue;
|
|
1749
|
+
}
|
|
1750
|
+
this.d.board.clearStall(t.id);
|
|
1751
|
+
if ((!t.deps?.length && !t.history.some(h => h.event === "backlog pulled")) || (!planned && last !== "blocked" && last !== "ready" && last !== "reserved")) continue;
|
|
1614
1752
|
// ponytail: a reserved owner (#207) that attaches after another peer that can take the task is passed over here like
|
|
1615
1753
|
// 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
1754
|
await this.offerReady(t, "nothing left to wait for");
|
|
1618
1755
|
}
|
|
1619
1756
|
}
|
|
@@ -1728,13 +1865,13 @@ export class Tasks {
|
|
|
1728
1865
|
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
1866
|
}
|
|
1730
1867
|
|
|
1731
|
-
private need(id: unknown, open = false, preEffect = false): Task {
|
|
1868
|
+
private need(id: unknown, open = false, preEffect = false, allowBacklog = false): Task {
|
|
1732
1869
|
// Callers are models (#70): an id is a whole number or a digit string, never an array that Number() would accept.
|
|
1733
1870
|
const n = typeof id === "number" ? id : typeof id === "string" && /^\s*\d+\s*$/.test(id) ? Number(id) : NaN;
|
|
1734
1871
|
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
1872
|
const task = this.d.board.get(n);
|
|
1736
1873
|
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`);
|
|
1874
|
+
if (task.state === "backlog" && !allowBacklog) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${n} is backlog: pull it into To Do first`);
|
|
1738
1875
|
// Handing a task to someone else only makes sense while there is work left on it.
|
|
1739
1876
|
if (open && !OPEN.includes(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: it can no longer change hands`);
|
|
1740
1877
|
return task;
|
|
@@ -1837,7 +1974,7 @@ export class Tasks {
|
|
|
1837
1974
|
const valid = (): Task => {
|
|
1838
1975
|
const t = this.need(id, false, true);
|
|
1839
1976
|
this.mine(t, by, "owner", true);
|
|
1840
|
-
if (t.state === "in_review" || t.state
|
|
1977
|
+
if (t.state === "in_review" || isClosedTaskState(t.state)) throw new PreEffectToolRefusal(`task #${t.id} is already ${t.state}`);
|
|
1841
1978
|
this.ready(t, true);
|
|
1842
1979
|
if (this.checking.has(t.id)) {
|
|
1843
1980
|
// The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
|
|
@@ -2013,7 +2150,7 @@ export class Tasks {
|
|
|
2013
2150
|
task = this.need(task.id, false, true);
|
|
2014
2151
|
this.mine(task, by, "owner", true);
|
|
2015
2152
|
// In every mode: another done may have landed during the readback.
|
|
2016
|
-
if (task.state === "in_review" || task.state
|
|
2153
|
+
if (task.state === "in_review" || isClosedTaskState(task.state)) throw new Error(`task #${task.id} is already ${task.state}`);
|
|
2017
2154
|
reviewer = task.reviewer;
|
|
2018
2155
|
}
|
|
2019
2156
|
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 +2356,7 @@ export class Tasks {
|
|
|
2219
2356
|
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
2357
|
const stopFormerOwner = (moved: Task) => {
|
|
2221
2358
|
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)));
|
|
2359
|
+
this.tell({ ...moved, owner: from }, `Task #${moved.id} moved to ${moved.owner} (${why}). Stop working on it.`, this.isPii(moved), "withdrawal"));
|
|
2223
2360
|
};
|
|
2224
2361
|
// Only our landed move authorizes a success notice and decision note. The former owner's stop
|
|
2225
2362
|
// message still names the current owner when a later assignment superseded this move.
|
|
@@ -2264,7 +2401,8 @@ export class Tasks {
|
|
|
2264
2401
|
? `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
2402
|
: `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
2403
|
if (peer === USER) this.d.notify(body);
|
|
2267
|
-
|
|
2404
|
+
// An untagged task notice remains needed by a former role until approval; kind review would look moved already.
|
|
2405
|
+
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
2406
|
});
|
|
2269
2407
|
}
|
|
2270
2408
|
|
|
@@ -2278,6 +2416,7 @@ export class Tasks {
|
|
|
2278
2416
|
const task = this.need(id, role === "owner", preEffect);
|
|
2279
2417
|
this.checkFlowRevision(task, expectedFlowRevision, preEffect);
|
|
2280
2418
|
if (!this.d.bus.isRostered(peer)) throw new (preEffect ? PreEffectToolRefusal : Error)(`${peer} is off roster; add it to the roster before assigning work`);
|
|
2419
|
+
this.requireCapabilities(task, peer, role, preEffect);
|
|
2281
2420
|
if (role === "reviewer") {
|
|
2282
2421
|
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
2422
|
this.ready(task, preEffect);
|
|
@@ -2303,7 +2442,7 @@ export class Tasks {
|
|
|
2303
2442
|
if (role !== "owner" && role !== "reviewer") throw new (preEffect ? PreEffectToolRefusal : Error)("role must be owner or reviewer");
|
|
2304
2443
|
const task = this.need(id, false, preEffect);
|
|
2305
2444
|
this.checkFlowRevision(task, expectedFlowRevision, preEffect);
|
|
2306
|
-
if (task.state
|
|
2445
|
+
if (isClosedTaskState(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its ${role} is historical`);
|
|
2307
2446
|
if (role === "owner") {
|
|
2308
2447
|
if (!OPEN.includes(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its owner cannot be unassigned`);
|
|
2309
2448
|
if (!task.owner && !task.reserved) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: no owner or owner reservation to unassign`);
|
|
@@ -2331,7 +2470,7 @@ export class Tasks {
|
|
|
2331
2470
|
/** Read-only preview using the same assignment constraints as the roster relay. */
|
|
2332
2471
|
rosterPreview(peer: PeerId): { id: number; title: string; role: "owner" | "reviewer"; to: PeerId | null }[] {
|
|
2333
2472
|
const routing = this.d.routing();
|
|
2334
|
-
return this.d.board.list().filter(task => task.state
|
|
2473
|
+
return this.d.board.list().filter(task => !isClosedTaskState(task.state) && (task.owner === peer || task.reviewer === peer)).map(task => {
|
|
2335
2474
|
const owner = task.owner === peer;
|
|
2336
2475
|
const weights = this.weights(task.class);
|
|
2337
2476
|
const pii = this.isPii(task);
|
|
@@ -2377,7 +2516,7 @@ export class Tasks {
|
|
|
2377
2516
|
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
2517
|
}
|
|
2379
2518
|
moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
|
|
2380
|
-
} else if (task.reviewer === peer && task.state
|
|
2519
|
+
} else if (task.reviewer === peer && !isClosedTaskState(task.state)) {
|
|
2381
2520
|
const a = assign(task, this.states(), routing, { exclude: [peer], candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
|
|
2382
2521
|
const reviewer = a.reviewer && a.reviewer !== peer && a.reviewer !== task.owner ? a.reviewer : null;
|
|
2383
2522
|
if (roster && !reviewer) {
|
|
@@ -2570,9 +2709,9 @@ export class Tasks {
|
|
|
2570
2709
|
}
|
|
2571
2710
|
}
|
|
2572
2711
|
|
|
2573
|
-
private tell(task: Task, body: string, pii: boolean): void {
|
|
2712
|
+
private tell(task: Task, body: string, pii: boolean, audience: "owner" | "withdrawal" = "owner"): void {
|
|
2574
2713
|
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 } : {}) }));
|
|
2714
|
+
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
2715
|
}
|
|
2577
2716
|
}
|
|
2578
2717
|
|
package/src/memory/capture.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BOUNDED_TASK_TOOLS } from "../hub/hub-tools.ts";
|
|
1
2
|
import { MEMORY_READ_TOOLS } from "../hub/read-tools.ts";
|
|
2
3
|
import { readFileSync } from "node:fs";
|
|
3
4
|
import { homedir } from "node:os";
|
|
@@ -48,7 +49,7 @@ export class Capture {
|
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
observe(call: { tool: string; args: string; output: string; id: string; paths: string[] }): void {
|
|
51
|
-
if (!this.sessionId || (MEMORY_READ_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_memory_correct") || this.opts.skip?.includes(call.tool)) return;
|
|
52
|
+
if (!this.sessionId || ((MEMORY_READ_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || BOUNDED_TASK_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, ""))) || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_memory_correct") || this.opts.skip?.includes(call.tool)) return;
|
|
52
53
|
// Anything that names a denylisted path is kept out of memory entirely, input and output alike.
|
|
53
54
|
if (call.paths.some((p) => p.split(/\s+/).some((word) => isDenied(word, this.opts.deny)))) return;
|
|
54
55
|
void this.client.request("POST", "/api/sessions/observations", {
|