@staix/agent-hub 0.20.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -3
- package/README.md +2 -2
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +7 -3
- package/docs/agent-notes/benchmarks.md +27 -0
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/bus.md +2 -2
- package/docs/agent-notes/daemon.md +15 -6
- package/docs/agent-notes/local-worker.md +4 -0
- package/docs/agent-notes/models.md +2 -0
- package/docs/agent-notes/tasks.md +17 -9
- package/docs/agent-notes/tests.md +9 -2
- package/docs/bench.md +1 -1
- package/docs/data-policy.md +2 -0
- package/docs/dogfood.md +37 -4
- package/docs/events.md +2 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +565 -62
- package/docs/profiles.md +13 -1
- package/docs/quickstart.md +14 -9
- package/docs/research.md +7 -4
- package/docs/sdlc.md +54 -10
- package/docs/security.md +396 -34
- package/docs/smoke.md +127 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +1070 -22
- package/docs/verification/2026-10-27-0.19.0.md +5 -5
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
- package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
- package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
- package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
- package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
- package/docs/verification/2026-10-29-0.21.0.md +61 -0
- package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
- package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
- package/docs/verification/claude-implementer-permission-triage.ts +160 -0
- package/docs/verification/claude-permission-transport-native.py +71 -14
- package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
- package/docs/verified.json +15 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1016 -280
- package/src/adapters/acp.ts +79 -3
- package/src/adapters/channel-peer.ts +4 -0
- package/src/adapters/claude-channel.ts +13 -6
- package/src/adapters/claude-implementer.ts +60 -7
- package/src/adapters/codex-appserver.ts +77 -4
- package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
- package/src/adapters/conformance/kit.ts +3 -9
- package/src/adapters/definitions/builtin-metadata.ts +3 -114
- package/src/adapters/definitions/native-config-defaults.ts +1 -1
- package/src/adapters/definitions/native-config-policy.ts +3 -2
- package/src/adapters/definitions/native-context.ts +12 -0
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-modules.ts +63 -3
- package/src/adapters/jcode.ts +6 -2
- package/src/adapters/local-worker.ts +40 -25
- package/src/adapters/native-proof-ports.ts +10 -0
- package/src/adapters/registry-types.ts +4 -0
- package/src/broker/protocol.ts +203 -0
- package/src/broker/server.ts +314 -0
- package/src/broker/store.ts +548 -0
- package/src/cli/bench.ts +3 -2
- package/src/cli/broker.ts +58 -0
- package/src/cli/conductor-recovery.ts +142 -0
- package/src/cli/configured-acp-entry.ts +26 -6
- package/src/cli/console-state.ts +59 -17
- package/src/cli/console.ts +17 -2
- package/src/cli/facts-hook.ts +3 -1
- package/src/cli/help.ts +28 -16
- package/src/cli/identity.ts +23 -10
- package/src/cli/init.ts +34 -7
- package/src/cli/intake-output.ts +33 -0
- package/src/cli/main.ts +314 -71
- package/src/cli/output.ts +41 -1
- package/src/cli/recovery-package.ts +18 -1
- package/src/cli/role-holders.ts +35 -0
- package/src/cli/room.ts +304 -0
- package/src/cli/say-input.ts +16 -0
- package/src/cli/schedule-output.ts +49 -0
- package/src/cli/sdlc-output.ts +44 -6
- package/src/cli/smoke-pty-bridge.ts +33 -0
- package/src/cli/smoke-pty.ts +145 -0
- package/src/cli/smoke-run.ts +139 -0
- package/src/cli/smoke-target.ts +130 -0
- package/src/cli/source-output.ts +29 -0
- package/src/cli/task-output.ts +3 -2
- package/src/cli/upgrade-runtime.ts +98 -16
- package/src/cli/upgrade.ts +66 -7
- package/src/hub/ask.ts +76 -12
- package/src/hub/board.ts +132 -22
- package/src/hub/bus.ts +399 -56
- package/src/hub/child-process.ts +2 -0
- package/src/hub/ci-watch.ts +42 -20
- package/src/hub/claude-failure-stage.ts +85 -0
- package/src/hub/claude-shell-wrapper.ts +11 -5
- package/src/hub/conductor.ts +17 -4
- package/src/hub/config-apply.ts +32 -5
- package/src/hub/config-reload.ts +7 -5
- package/src/hub/config-trust.ts +7 -1
- package/src/hub/context-window.ts +33 -3
- package/src/hub/control-client.ts +14 -2
- package/src/hub/corpus-config.ts +34 -0
- package/src/hub/corpus-evidence.ts +184 -0
- package/src/hub/corpus.ts +373 -0
- package/src/hub/cost-governance.ts +26 -0
- package/src/hub/daemon.ts +1150 -171
- package/src/hub/deliverable-files.ts +8 -2
- package/src/hub/delivery-journal.ts +123 -17
- package/src/hub/dogfood-profile.ts +32 -5
- package/src/hub/dogfood-read.ts +6 -2
- package/src/hub/envelope.ts +11 -0
- package/src/hub/eval-gate.ts +131 -0
- package/src/hub/events.ts +1 -1
- package/src/hub/external-instance.ts +69 -0
- package/src/hub/facts.ts +15 -5
- package/src/hub/folder-trigger.ts +223 -0
- package/src/hub/hub-tools.ts +2 -1
- package/src/hub/inference.ts +20 -5
- package/src/hub/instance-config.ts +69 -3
- package/src/hub/instance-execution.ts +6 -2
- package/src/hub/instances.ts +32 -4
- package/src/hub/intake.ts +125 -0
- package/src/hub/learning.ts +85 -0
- package/src/hub/lifecycle.ts +7 -3
- package/src/hub/limits.ts +12 -5
- package/src/hub/native-permission-request.ts +77 -0
- package/src/hub/operational-authority.ts +63 -5
- package/src/hub/operational-settings.ts +76 -0
- package/src/hub/operational-view.ts +1 -1
- package/src/hub/packs.ts +132 -0
- package/src/hub/path-policy.ts +7 -0
- package/src/hub/peer-doing.ts +130 -0
- package/src/hub/peers.ts +12 -0
- package/src/hub/queue-backpressure.ts +58 -0
- package/src/hub/read-tools.ts +2 -0
- package/src/hub/recovery-authority.ts +151 -0
- package/src/hub/recovery-policy.ts +220 -0
- package/src/hub/research.ts +24 -14
- package/src/hub/restart.ts +7 -0
- package/src/hub/routing.ts +54 -11
- package/src/hub/schedules.ts +266 -0
- package/src/hub/sdlc-merge.ts +38 -14
- package/src/hub/sdlc-metrics.ts +32 -10
- package/src/hub/sdlc-queue.ts +7 -1
- package/src/hub/sdlc-release.ts +1 -1
- package/src/hub/sdlc.ts +37 -7
- package/src/hub/security-baseline.ts +38 -0
- package/src/hub/settings.ts +6 -1
- package/src/hub/shipped-packs.ts +8 -0
- package/src/hub/snapshots.ts +72 -6
- package/src/hub/source-read.ts +160 -0
- package/src/hub/sources.ts +311 -0
- package/src/hub/tasks.ts +910 -110
- package/src/hub/workflow.ts +1 -1
- package/src/local/doc-read.ts +69 -0
- package/src/local/proxy.ts +4 -13
- package/src/local/tools.ts +37 -3
- package/src/local/web-fetch-config.ts +43 -0
- package/src/local/web-fetch.ts +183 -0
- package/src/memory/brief.ts +85 -13
- package/src/memory/capture.ts +1 -1
- package/src/rooms/application.ts +114 -0
- package/src/rooms/boundary.ts +103 -0
- package/src/rooms/commands.ts +294 -0
- package/src/rooms/crypto.ts +371 -0
- package/src/rooms/link.ts +584 -0
- package/src/rooms/policy.ts +82 -0
- package/src/rooms/remote-peer.ts +80 -0
- package/src/rooms/service.ts +426 -0
- package/src/rooms/status.ts +146 -0
- package/src/rooms/store.ts +1210 -0
- package/src/rooms/wire.ts +465 -0
- package/src/ui/index.html +91 -4
- package/templates/packs/example.pack.json +13 -0
package/src/hub/tasks.ts
CHANGED
|
@@ -1,21 +1,28 @@
|
|
|
1
|
+
import type { ContextAdmission } from "./context-window.ts";
|
|
2
|
+
import { queueBackpressure, queueBackpressurePolicy, type QueueBackpressureCode, type QueueBackpressurePolicy, type QueueBackpressureStatus, type QueuePressure } from "./queue-backpressure.ts";
|
|
1
3
|
import { canonicalMemorySymbolNode, normalizeMemorySymbolInput, MEMORY_SYMBOL_LIMIT, type MemorySymbolNodes } from "./memory-symbols.ts";
|
|
2
4
|
import { createHash } from "node:crypto";
|
|
3
5
|
import { taskShellBindingValid, type TaskShellBinding } from "./task-shell-command.ts";
|
|
4
6
|
import { buildTaskCodeSymbols, publicTaskCodeSymbols, taskCodeSymbolsDisjoint, HOST_CODE_SYMBOLS } from "./task-code-symbols.ts";
|
|
5
7
|
import { normalizePlanSymbol, symbolsOverlap } from "./symbol-normalizer.ts";
|
|
6
8
|
import { publicTestIntegrity, readTestIntegrity, testIntegrityBlockers, type TestIntegrity } from "./test-integrity.ts";
|
|
9
|
+
import { evalAdvice, evalGapBlockers, evalRequiredChecks, readEvalFor } from "./eval-gate.ts";
|
|
7
10
|
import { completePlanInventory, planConformance, planConformanceBlockers, planFingerprint, type PlanConformance } from "./plan-conformance.ts";
|
|
8
11
|
import { RELEASE_TRAIN_HISTORY_LIMIT, releaseTrainData, releaseTrainReadiness, releaseMetrics } from "./sdlc-release.ts";
|
|
9
12
|
import { cleanCostPlan, costMetrics, type CostPlan, type ReviewCost } from "./cost-governance.ts";
|
|
10
13
|
import { qualifiedTitleRepos, siblingTitleRefs, specRefs } from "./spec-read.ts";
|
|
11
|
-
import {
|
|
14
|
+
import { folderDetail, MAX_NEW_PER_POLL, parseEvery, SOURCE_ID, scheduledDetail, scheduleKeyOf, type FolderEntry, type FolderRunResult, type FolderScan, type FolderStorePort, type ScannedFile, type ScheduleEntry, type ScheduleRunResult, type ScheduleStore } from "./schedules.ts";
|
|
15
|
+
import { INTAKE_MAX_SOURCES, INTAKE_MAX_TEXT, intakeDetail, intakeSystemPrompt, intakeUserMessage, parseIntakeDetail, parseIntakeSplit, plannerRequestBody } from "./intake.ts";
|
|
16
|
+
import { DEFAULT_BAND, EPISODE_LOOKBACK, sdlcMetrics, type BandMetric, type BandResult, type MetricTask, type MetricsReport, type SeriesPoint } from "./sdlc-metrics.ts";
|
|
17
|
+
import { readSecurityScan } from "./security-baseline.ts";
|
|
18
|
+
import { cleanMergeCost } from "./cost-governance.ts";
|
|
12
19
|
import type { InstancesConfig } from "./instances.ts";
|
|
13
20
|
import { composeRememberText } from "./memory-fail.ts";
|
|
14
21
|
import { VERSION } from "../version.ts";
|
|
15
22
|
import { NoWriteTurns } from "./no-writes.ts";
|
|
16
23
|
import { readGitHubImport, type GitHubSource, type GitHubImportSelection } from "./github-import.ts";
|
|
17
24
|
import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS, actualTaskDates, planningPatch, type PlanningFields, type Release, type ReleaseState } from "./planning.ts";
|
|
18
|
-
import { readMergeQueue, type DeliveryTask } from "./sdlc-queue.ts";
|
|
25
|
+
import { readMergeQueue, type DeliveryTask, type MergeQueue } from "./sdlc-queue.ts";
|
|
19
26
|
import { guardedMerge, mergeModeFor, mergeView, type MergeView } from "./sdlc-merge.ts";
|
|
20
27
|
import { mirrorVerdict as mirrorToGitHub, type MirrorVerdict } from "./sdlc-mirror.ts";
|
|
21
28
|
import { CODE_SCOPE_LINE, evidenceOnlyNotice, type ReviewScope } from "./review-scope.ts";
|
|
@@ -28,17 +35,18 @@ import type { MemoryScreen } from "./read-tools.ts";
|
|
|
28
35
|
import { withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
|
|
29
36
|
import { profileClass, profileData, profileTaskLabel, projectProfile, type ProfileName, type ProfileSelection, type ProjectProfile } from "./profiles.ts";
|
|
30
37
|
import { criteriaView, unmetCriteriaIds, workflowHash } from "./workflow.ts";
|
|
38
|
+
import { candidateCard, criterionKey, groupUnmetCriteria, LEARNING_DEFAULTS, selectGroups, type CriterionGroup } from "./learning.ts";
|
|
31
39
|
import { approvedDeliverables, latestDeliverables, parseDeliverableClaims, publicDeliverables, type DeliverableRecord, type DeliverableStatus } from "./deliverables.ts";
|
|
32
40
|
import { checkDeliverables, DeliverableRefusal, hashDeliverables, vetDeliverables, type DeliverablePorts } from "./deliverable-files.ts";
|
|
33
41
|
import { isAbsolute, relative } from "node:path";
|
|
34
42
|
import type { Briefs, NativeItem } from "../memory/brief.ts";
|
|
35
43
|
import type { MemoryClient } from "../memory/client.ts";
|
|
36
44
|
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";
|
|
37
|
-
import { CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskCanPlan, taskHasWorkEvidence, taskHasStarted, planningUndoEntry, isClosedTaskState, type TaskStall, type HistoryEntry, type ReviewerHistory, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs, type PlannerBinding } from "./board.ts";
|
|
45
|
+
import { parseClaimVerdict, claimListId, CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskCanPlan, taskHasWorkEvidence, taskHasStarted, planningUndoEntry, isClosedTaskState, type TaskStall, type HistoryEntry, type ReviewerHistory, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs, type PlannerBinding } from "./board.ts";
|
|
38
46
|
import type { Bus } from "./bus.ts";
|
|
39
47
|
import { HUB, newEnvelope, NOTE_KINDS, noteLine, sanitize, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
|
|
40
48
|
import type { PiiVerdict, ScreenItem, ScreenRecord } from "./inference.ts";
|
|
41
|
-
import { assign, detectSignals, peerEntry, predictSplit, relayCandidates, type Assignment, type InstanceRoutingOptions, type PeerCapabilities, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
|
|
49
|
+
import { assign, isRemotePeerId, detectSignals, peerEntry, predictSplit, relayCandidates, type Assignment, type InstanceRoutingOptions, type PeerCapabilities, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
|
|
42
50
|
import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
|
|
43
51
|
import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
|
|
44
52
|
import { realPath } from "./project.ts";
|
|
@@ -52,6 +60,10 @@ import { plannerGrooming, nextSweep, taskSweepConfig, type TaskSweepConfig, type
|
|
|
52
60
|
import { CiWatchers, pollCi, type CiOutcome, type CiWatch } from "./ci-watch.ts";
|
|
53
61
|
import { planningIssueReceipt, linkedReceipt, openPrFor, readPrCostHistory, readReviewReadiness, reviewReadinessFromEvidence, type PrEvidence, type ReviewReadiness, readPlanIssueMetadata, canonicalStage, checklistOf, readSdlcPr, readSpecEdit, sdlcConfig, sdlcReceipt, sdlcStatus, specState, SPEC_STATE_TEXT, stageDefinition, lifecycleStage, type SdlcConfig, type MergeRecord, type PlanIssueMetadata, sdlcGh, type SdlcGh, type SdlcStatus, type SpecEdit, type SpecState } from "./sdlc.ts";
|
|
54
62
|
|
|
63
|
+
function requireLocalTaskPeer(peer: unknown): void {
|
|
64
|
+
if (isRemotePeerId(peer)) throw new PreEffectToolRefusal("remote peers have no local task authority");
|
|
65
|
+
}
|
|
66
|
+
|
|
55
67
|
export type TaskAssignmentRole = "owner" | "reviewer" | "planner";
|
|
56
68
|
|
|
57
69
|
export interface ReleaseWindow { at: number; host: boolean; quota: boolean; idle: boolean; reasons: string[]; current?: () => boolean; close?: () => Promise<void>; }
|
|
@@ -68,6 +80,10 @@ export interface TasksDeps {
|
|
|
68
80
|
board: Board;
|
|
69
81
|
bus: Bus;
|
|
70
82
|
routing: () => Routing;
|
|
83
|
+
/** #800: normalized machine-local admission policy; absent or both zero preserve the old queue behaviour. */
|
|
84
|
+
queueBackpressure?: () => QueueBackpressurePolicy;
|
|
85
|
+
/** Host-only native context snapshots, separate from checkpoint notification and quota. */
|
|
86
|
+
contextAdmission?: () => ContextAdmission;
|
|
71
87
|
/** Startup-bound project declarations; routing never discovers instances from peer names. */
|
|
72
88
|
instances?: () => InstancesConfig;
|
|
73
89
|
/** Host proof of each live extra runtime's actual isolated execution root; config paths alone are not proof. */
|
|
@@ -120,6 +136,11 @@ export interface TasksDeps {
|
|
|
120
136
|
reviewAuthority?: (by: PeerId, task: number) => (() => string | undefined) | undefined;
|
|
121
137
|
/** Host-captured current-child authority, rechecked before an admitted async tool first commits effects. */
|
|
122
138
|
requestAuthority?: (by: PeerId) => (() => string | undefined) | undefined;
|
|
139
|
+
/**
|
|
140
|
+
* #933: whether `by` runs this call inside an admitted, audited conductor operation. The host rechecks the role on every call and throws
|
|
141
|
+
* its authority refusal when it ended; a peer outside such a call gets `false`. Host-only: never read from configuration or a tool.
|
|
142
|
+
*/
|
|
143
|
+
operational?: (by: PeerId) => boolean;
|
|
123
144
|
memory?: MemoryClient;
|
|
124
145
|
/** Captured native profile fence at our memory operation boundaries; no board mutation. */
|
|
125
146
|
memoryCheck?: () => void;
|
|
@@ -129,6 +150,8 @@ export interface TasksDeps {
|
|
|
129
150
|
notify: (line: string) => void;
|
|
130
151
|
/** The completion check configured for a class, if any (local config only; issue #7). */
|
|
131
152
|
check?: (cls: TaskClass, task?: Task) => string | undefined;
|
|
153
|
+
/** #901: the key of the `checks` entry `check` chose for this task (the profile class alias, else the class): the same lookup, so the two cannot disagree. Absent: the key is unknown. */
|
|
154
|
+
checkKey?: (cls: TaskClass, task?: Task) => string | undefined;
|
|
132
155
|
/** Runs one check; the hub runs them one at a time. */
|
|
133
156
|
runCheck?: (command: string) => Promise<{ code: number | null; timedOut: boolean; interrupted?: boolean; tail: string }>;
|
|
134
157
|
/** Hands a saved note to every other peer; it rides on their next delivery. */
|
|
@@ -159,6 +182,8 @@ export interface TasksDeps {
|
|
|
159
182
|
roles?: Record<string, string[]>;
|
|
160
183
|
/** Optional: name a class for a task proposed without one. `onCampus` says whether the model call stays on campus. */
|
|
161
184
|
triage?: { classify: (title: string, detail: string) => Promise<TaskClass | undefined>; onCampus: () => Promise<boolean> };
|
|
185
|
+
/** #334: natural-language intake. `split` is the hub model's one call, `partsSchema` the profile's `hub_backlog_split` parts schema, `sourceKnown` the source registry. */
|
|
186
|
+
intake?: { split: (system: string, user: string, opts: { campusOnly: boolean }) => Promise<string | undefined>; partsSchema: () => unknown; sourceKnown: (id: string) => boolean };
|
|
162
187
|
/** The PII screen (issue #198): a closed verdict from a model on this machine or on campus, `unknown` on any failure. */
|
|
163
188
|
piiScreen?: (text: string) => Promise<PiiVerdict>;
|
|
164
189
|
/** Each screen verdict, for the record: item, label, source and category, never the text. */
|
|
@@ -191,27 +216,43 @@ export interface TasksDeps {
|
|
|
191
216
|
/** A cohort formed, changed or was lifted (issue #107), for the record: the benchmark's treatment check reads it. */
|
|
192
217
|
recordCohort?: (cohort: { id: number; event: "formed" | "joined" | "lifted"; silent: boolean; tasks: number[]; owners: PeerId[] }) => void;
|
|
193
218
|
sweep?: TaskSweepConfig;
|
|
194
|
-
/**
|
|
219
|
+
/** #798 D1: the project's declared submodule paths, project-relative. Absent: none are known, so no notice. */
|
|
220
|
+
submodules?: () => string[];
|
|
221
|
+
/** Shutdown or coordinator work (recovery) holds task sweeps, and a guarded merge at its dispatch (#491). */
|
|
195
222
|
sweepHeld?: () => boolean;
|
|
196
223
|
/** Consecutive changes_requested verdicts that move a task to another peer (`tasks.escalate_after`, #488). */
|
|
197
224
|
escalateAfter?: () => number;
|
|
225
|
+
/** #489 H1: current host configuration, read at each planner-notice flush; no task/request override. */
|
|
226
|
+
groomFyiWindowMin?: () => number;
|
|
227
|
+
/** #489 H2: current host configuration, read at each use (no cache, no task/request override); absent is the constant each replaces. */
|
|
228
|
+
demoteAt?: () => number;
|
|
229
|
+
demotionHalfLifeH?: () => number;
|
|
230
|
+
contradictionWindowDays?: () => number;
|
|
231
|
+
rescreenMax?: () => number;
|
|
232
|
+
splitMinObservations?: () => number;
|
|
233
|
+
splitMaxFailureShare?: () => number;
|
|
198
234
|
}
|
|
199
235
|
|
|
200
236
|
/** The `tasks.escalate_after` default (#488): the third consecutive changes_requested escalates. */
|
|
201
237
|
export const ESCALATE_AFTER = 3;
|
|
202
238
|
// Demotion (issue #36): a failure counts half after a day. A peer is demoted for a class once its decayed failures
|
|
203
239
|
// reach 1.5 (two within about half a day, three within two days) and outweigh its decayed successes there.
|
|
204
|
-
//
|
|
205
|
-
const DEMOTION_HALF_LIFE_MS = 24 * 3_600_000;
|
|
206
|
-
const DEMOTE_AT = 1.5;
|
|
207
|
-
/** An approval counts as contradicted when work on the same places fails within this window (issue #35). */
|
|
208
|
-
const CONTRADICTION_WINDOW_MS = 7 * 86_400_000;
|
|
240
|
+
// #489 H2: `tasks.demotion_half_life_h` and `tasks.demote_at` set these; unset they are exactly the constants below (pinned in test/operational-settings.test.ts).
|
|
241
|
+
export const DEMOTION_HALF_LIFE_MS = 24 * 3_600_000;
|
|
242
|
+
export const DEMOTE_AT = 1.5;
|
|
243
|
+
/** An approval counts as contradicted when work on the same places fails within this window (issue #35); `tasks.contradiction_window_days` sets it. */
|
|
244
|
+
export const CONTRADICTION_WINDOW_MS = 7 * 86_400_000;
|
|
209
245
|
const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
|
|
246
|
+
const QUEUE_PRESSURE_CODES: readonly QueueBackpressureCode[] = ["queue-count", "queue-age", "queue-age-unknown"];
|
|
247
|
+
const isQueuePressureCode = (code: string): code is QueueBackpressureCode => (QUEUE_PRESSURE_CODES as readonly string[]).includes(code);
|
|
248
|
+
const QUEUE_RECHECK_PAGE = 32;
|
|
249
|
+
interface QueuePressurePass { ownerAfter: number; reviewAfter: number; ownerDone: boolean; reviewDone: boolean; retained: boolean; generation: number }
|
|
210
250
|
/** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
|
|
211
251
|
const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep", "memory reference started"]);
|
|
212
252
|
QUIET_EVENTS.add("ready held"); // #655: the hub's note that a ready task waits for its reserved owner's WIP slot.
|
|
213
253
|
QUIET_EVENTS.add("plan observed"); // Host observation does not move the task or replace completion intent.
|
|
214
254
|
QUIET_EVENTS.add("planner changed"); // Planning authority does not move the execution owner or revoke its handoff.
|
|
255
|
+
QUIET_EVENTS.add("claim verdict"); // #901: a person's bookkeeping on a plan claim moves nothing, so it must not turn a running check into "check finished late".
|
|
215
256
|
QUIET_EVENTS.add("shell branch bound"); // #880: the hub's host-observed branch binding (#845) is bookkeeping; it neither moves nor settles the task.
|
|
216
257
|
/** The records of the `done (checking)` entry a passed check now completes: what the owner delivered then (#509a). */
|
|
217
258
|
const latestPendingDeliverables = (task: Task): DeliverableRecord[] | undefined => {
|
|
@@ -312,11 +353,11 @@ const planText = (plan: TaskPlan = {}) => ALL_PLAN_KEYS.filter((k) => plan[k]?.l
|
|
|
312
353
|
/** What the PII screen reads of a task (#198): the text the patterns are checked against. */
|
|
313
354
|
const screenText = (t: { title: string; detail: string; plan?: TaskPlan; external?: Task["external"] }) => [t.title, t.detail, planText(t.plan), t.external?.repo, t.external?.url].filter(value => value !== undefined).join("\n");
|
|
314
355
|
/** Re-screens of one task whose verdict was unknown, per hub run (#198). */
|
|
315
|
-
const RESCREEN_MAX = 10;
|
|
356
|
+
export const RESCREEN_MAX = 10;
|
|
316
357
|
/** #758: the planner "board changed" notice. Its body starts with this, so an unsettled copy is found (and replaced) by the prefix. */
|
|
317
358
|
const GROOM_NOTICE_PREFIX = "The board changed";
|
|
318
359
|
/** #758: a board change that needs no plan review is recorded at most once per planner in this window. */
|
|
319
|
-
const GROOM_FYI_WINDOW_MS = 10 * 60_000;
|
|
360
|
+
export const GROOM_FYI_WINDOW_MS = 10 * 60_000;
|
|
320
361
|
|
|
321
362
|
/**
|
|
322
363
|
* The task flow. Adapters and tools never touch the board: every change comes through here, where assignment,
|
|
@@ -424,15 +465,16 @@ export class Tasks {
|
|
|
424
465
|
}
|
|
425
466
|
|
|
426
467
|
/** `signals.pii_screen = "local"` where the PII constraint applies (issue #198); on without a screener, every verdict is unknown. */
|
|
427
|
-
|
|
468
|
+
screenOn = (): boolean => {
|
|
428
469
|
const r = this.d.routing();
|
|
429
470
|
return r.signals.pii_screen === "local" && r.constraints.pii === "local_only";
|
|
430
471
|
};
|
|
431
472
|
|
|
432
473
|
/** One bounded screen call. A screener that fails is no verdict, handled as PII (fail closed). */
|
|
433
|
-
private requestCheck(by: PeerId): () => void {
|
|
474
|
+
private requestCheck(by: PeerId, fence?: () => string | undefined): () => void {
|
|
475
|
+
requireLocalTaskPeer(by);
|
|
434
476
|
const authority = this.d.requestAuthority?.(by);
|
|
435
|
-
const check = () => { const reason = authority?.(); if (reason) throw new PreEffectToolRefusal(reason); };
|
|
477
|
+
const check = () => { const reason = authority?.() ?? fence?.(); if (reason) throw new PreEffectToolRefusal(reason); };
|
|
436
478
|
check(); return check;
|
|
437
479
|
}
|
|
438
480
|
|
|
@@ -551,6 +593,7 @@ export class Tasks {
|
|
|
551
593
|
|
|
552
594
|
/** Captured at native turn start, not supplied by a model. */
|
|
553
595
|
ownerTurnTasks(peer: PeerId): { id: number; acceptance: number }[] {
|
|
596
|
+
requireLocalTaskPeer(peer);
|
|
554
597
|
return this.d.board.list("in_progress").flatMap(task => {
|
|
555
598
|
const accepted = task.owner === peer && ["implement", "bulk_edit"].includes(task.class) ? this.ownerAcceptance(task) : undefined;
|
|
556
599
|
return accepted ? [{ id: task.id, acceptance: accepted.index }] : [];
|
|
@@ -558,6 +601,7 @@ export class Tasks {
|
|
|
558
601
|
}
|
|
559
602
|
|
|
560
603
|
recordOwnerTurn(peer: PeerId, bindings: readonly { id: number; acceptance: number }[], changed: boolean | undefined, effect: boolean): void {
|
|
604
|
+
requireLocalTaskPeer(peer);
|
|
561
605
|
this.noWriteTurns.retain(this.d.board.list("in_progress").filter(task => ["implement", "bulk_edit"].includes(task.class)).map(task => task.id));
|
|
562
606
|
const current = new Map(this.ownerTurnTasks(peer).map(binding => [binding.id, binding.acceptance]));
|
|
563
607
|
for (const binding of bindings) {
|
|
@@ -662,6 +706,33 @@ export class Tasks {
|
|
|
662
706
|
}
|
|
663
707
|
return new Set(rows.map(row => find(row.id))).size;
|
|
664
708
|
}
|
|
709
|
+
/**
|
|
710
|
+
* #514a: the same recurrence path for recurring UNMET CRITERIA. A criterion id that reviews keep naming unmet on the same profile and class, on at least
|
|
711
|
+
* `threshold` tasks that are at least two separate pieces of work (`workUnits`), files one backlog card for a person to review, with the `profile_overrides`
|
|
712
|
+
* fragment as text. Nothing is applied. A PII task and a withheld or private-turn review are never evidence and never named; the card carries ids and counts only.
|
|
713
|
+
*/
|
|
714
|
+
private proposeCriterionCandidates(task: Task, by: PeerId, withheld: boolean): void {
|
|
715
|
+
if (withheld || this.isPii(task) || this.d.privateTurn?.(by) || !task.history.at(-1)?.unmetCriteria?.length) return;
|
|
716
|
+
const rows = this.d.board.list();
|
|
717
|
+
const keys = new Set(rows.filter(row => row.class === "plan" && row.history[0]?.by === HUB)
|
|
718
|
+
.flatMap(row => row.detail?.split("\n").filter(line => line.startsWith("Criterion candidate key: ")) ?? []));
|
|
719
|
+
// Only the rows that may count: candidateTextAllowed refuses a PII row and one whose text the screen refuses, so those are evidence of nothing.
|
|
720
|
+
const eligible = rows.filter(row => this.candidateTextAllowed(`${row.profile ?? ""} ${row.profileClass ?? ""} ${row.class}`, row));
|
|
721
|
+
const named = new Set(task.history.at(-1)!.unmetCriteria);
|
|
722
|
+
const created: number[] = [];
|
|
723
|
+
// No await separates the durable dedupe read from creation; concurrent verdicts see the first card.
|
|
724
|
+
const groups = groupUnmetCriteria(eligible, { windowMs: LEARNING_DEFAULTS.windowMs, now: Date.now() });
|
|
725
|
+
const unitsOf = (group: CriterionGroup) => this.workUnits(eligible.filter(row => group.tasks.includes(row.id)));
|
|
726
|
+
for (const group of selectGroups(groups, { named, threshold: LEARNING_DEFAULTS.threshold, max: LEARNING_DEFAULTS.maxGroups, has: g => keys.has(criterionKey(g)), units: unitsOf })) {
|
|
727
|
+
const card = candidateCard(group, Math.round(LEARNING_DEFAULTS.windowMs / 86_400_000), unitsOf(group));
|
|
728
|
+
const draft = { title: card.title, detail: card.detail, refs: { paths: ["docs/profiles.md"] } };
|
|
729
|
+
const signals = this.signalsFor(draft);
|
|
730
|
+
if (this.isPii({ signals }) || !this.candidateTextAllowed(`${card.title}\n${card.detail}`)) continue;
|
|
731
|
+
const proposal = this.d.board.propose(HUB, { ...draft, class: "plan", backlog: true, signals });
|
|
732
|
+
this.incomingBacklog.add(proposal.id); keys.add(criterionKey(group)); created.push(proposal.id);
|
|
733
|
+
}
|
|
734
|
+
for (const id of created) this.releaseNotice(`task #${id}: recurring unmet criterion candidate proposed in the backlog`);
|
|
735
|
+
}
|
|
665
736
|
private proposeRecurringFindings(task: Task, by: PeerId, items: readonly ReviewFinding[], withheld: boolean): void {
|
|
666
737
|
if (withheld || this.isPii(task) || this.d.privateTurn?.(by) || !items.length || !this.findingTextAllowed(task, items)) return;
|
|
667
738
|
const rows = this.d.board.list();
|
|
@@ -698,7 +769,14 @@ export class Tasks {
|
|
|
698
769
|
|
|
699
770
|
publicView(task: Task, fullHistory = false): Record<string, unknown> {
|
|
700
771
|
const { title, detail, history, external, cancelled, codeSymbols, ...taskRest } = task;
|
|
701
|
-
const
|
|
772
|
+
const plannerBinding = history.findLast(h => h.planner === task.planner && h.plannerReason !== undefined);
|
|
773
|
+
const ownerBinding = history.findLast(h => h.owner === task.owner && ["assigned", "reassigned", "escalated", "backlog pulled"].includes(h.event));
|
|
774
|
+
const reviewerBinding = history.findLast(h => ["assigned", "reassigned", "escalated", "done", "reviewer changed", "reviewer unavailable"].includes(h.event));
|
|
775
|
+
const rest = { ...taskRest,
|
|
776
|
+
...(plannerBinding?.plannerReason && ["rotation", "named", "kept", "none"].includes(plannerBinding.plannerReason) ? { plannerReason: plannerBinding.plannerReason } : {}),
|
|
777
|
+
...(ownerBinding?.reason && TASK_MOVE_REASONS.includes(ownerBinding.reason) ? { ownerReason: ownerBinding.reason === "manual" && ownerBinding.event === "reassigned" && ownerBinding.by !== HUB ? "named" : ownerBinding.reason } : {}),
|
|
778
|
+
...(reviewerBinding?.reviewerReason && ["rotation", "implementer skip", "unavailable skip", "same-family fallback"].includes(reviewerBinding.reviewerReason) ? { reviewerReason: reviewerBinding.reviewerReason } : reviewerBinding?.event === "reviewer changed" && reviewerBinding.reason === "manual" ? { reviewerReason: "named" } : {}),
|
|
779
|
+
...(history.some(h => h.by === USER && h.planDeparture) ? { planDepartureAccepted: true } : {}) };
|
|
702
780
|
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]" };
|
|
703
781
|
if (rest.sdlc?.redFirst && !this.publicMetadataText(JSON.stringify(rest.sdlc.redFirst))) {
|
|
704
782
|
const { redFirst: _withheld, ...receipt } = rest.sdlc; rest.sdlc = receipt;
|
|
@@ -723,6 +801,7 @@ export class Tasks {
|
|
|
723
801
|
...(h.unmetCriteria?.length ? { unmetCriteria: h.unmetCriteria } : {}),
|
|
724
802
|
...(h.deliverables?.length ? { deliverables: publicDeliverables(h.deliverables, this.nameable) } : {}),
|
|
725
803
|
...(h.reviewBlocked ? { reviewBlocked: true } : {}),
|
|
804
|
+
...(h.queueReviewHeld ? { queueReviewHeld: true } : {}),
|
|
726
805
|
...(h.reviewerFailure ? { reviewerFailure: { peer: screenHistoryText(h.reviewerFailure.peer, "reviewer"), cause: h.reviewerFailure.cause, ...(h.reviewerFailure.head ? { head: screenHistoryText(h.reviewerFailure.head, "review head") } : {}) } } : {}),
|
|
727
806
|
...(h.planning ? { planning: { revision: h.planning.revision, fields: h.planning.fields, ...(h.planning.split ? { children: h.planning.split.children.map(child => child.id) } : {}) } } : {}),
|
|
728
807
|
...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
|
|
@@ -757,14 +836,15 @@ export class Tasks {
|
|
|
757
836
|
/** Decayed failure weights of the peers demoted for a class: failures count half after a day (issue #36). */
|
|
758
837
|
demoted(cls: TaskClass, now = Date.now()): Record<PeerId, number> {
|
|
759
838
|
const sums = new Map<PeerId, { bad: number; good: number }>();
|
|
839
|
+
const halfLifeH = this.d.demotionHalfLifeH?.(), halfLifeMs = halfLifeH === undefined ? DEMOTION_HALF_LIFE_MS : halfLifeH * 3_600_000, demoteAt = this.d.demoteAt?.() ?? DEMOTE_AT;
|
|
760
840
|
for (const o of this.d.board.outcomes(cls, now - OUTCOMES_KEPT_MS)) {
|
|
761
|
-
const w = 0.5 ** ((now - o.at) /
|
|
841
|
+
const w = 0.5 ** ((now - o.at) / halfLifeMs);
|
|
762
842
|
const s = sums.get(o.peer) ?? { bad: 0, good: 0 };
|
|
763
843
|
if (o.ok) s.good += w;
|
|
764
844
|
else s.bad += w;
|
|
765
845
|
sums.set(o.peer, s);
|
|
766
846
|
}
|
|
767
|
-
return Object.fromEntries([...sums].filter(([, s]) => s.bad >=
|
|
847
|
+
return Object.fromEntries([...sums].filter(([, s]) => s.bad >= demoteAt && s.bad > s.good).map(([p, s]) => [p, s.bad]));
|
|
768
848
|
}
|
|
769
849
|
|
|
770
850
|
/** How each reviewer's reviews of each implementer's work in a class held up (issue #35). */
|
|
@@ -801,20 +881,57 @@ export class Tasks {
|
|
|
801
881
|
}
|
|
802
882
|
|
|
803
883
|
/** Workload is board/queue data only: wip() never calls health() or weights(). */
|
|
804
|
-
private instanceRouting(): InstanceRoutingOptions {
|
|
884
|
+
private instanceRouting(ownerIsolation?: Readonly<Record<string, boolean>>): InstanceRoutingOptions {
|
|
805
885
|
const instances = this.d.instances?.();
|
|
806
886
|
if (!instances || !Object.keys(instances).length) return { adapterCatalog: this.d.adapterCatalog };
|
|
807
887
|
const peers = [...new Set([...(this.d.adapterCatalog?.definitions ?? ADAPTERS).map(adapter => adapter.kind), ...Object.keys(instances), ...this.d.bus.peers.keys()])];
|
|
808
|
-
return { adapterCatalog: this.d.adapterCatalog, instances, ownerIsolation: this.d.ownerIsolation?.() ?? {}, wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])), load: Object.fromEntries(peers.map(peer => [peer, this.d.bus.queued(peer)])) };
|
|
888
|
+
return { adapterCatalog: this.d.adapterCatalog, instances, ownerIsolation: ownerIsolation ?? this.d.ownerIsolation?.() ?? {}, wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])), load: Object.fromEntries(peers.map(peer => [peer, this.d.bus.queued(peer)])) };
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
private queuePolicy(): QueueBackpressurePolicy | undefined {
|
|
892
|
+
const policy = queueBackpressurePolicy(this.d.queueBackpressure?.());
|
|
893
|
+
return policy.max_queued > 0 || policy.max_oldest_s > 0 ? policy : undefined;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/** Admission is pending metadata only; an unavailable read never invents an empty/fresh queue. */
|
|
897
|
+
private queueRouting(peers: readonly PeerId[], policy = this.queuePolicy()): Pick<InstanceRoutingOptions, "queues" | "queueBackpressure"> & { now?: number } {
|
|
898
|
+
if (!policy) return {};
|
|
899
|
+
const queues = Object.fromEntries([...new Set(peers)].map(peer => {
|
|
900
|
+
let value: QueuePressure;
|
|
901
|
+
try { value = this.d.bus.assignmentQueue(peer); } catch { value = { queued: NaN }; }
|
|
902
|
+
return [peer, value];
|
|
903
|
+
}));
|
|
904
|
+
return { queueBackpressure: policy, queues, now: this.d.now?.() ?? Date.now() };
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
private contextRouting(): Pick<InstanceRoutingOptions, "contextAdmission"> & { now?: number } {
|
|
908
|
+
const admission = this.d.contextAdmission?.();
|
|
909
|
+
if (!admission || !Number.isFinite(admission.policy.max_used) || admission.policy.max_used <= 0 || admission.policy.max_used > 1) return {};
|
|
910
|
+
return { contextAdmission: admission, now: this.d.now?.() ?? Date.now() };
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/** Public ids/counts/reasons only; no board history, pending envelope body or task text is read. */
|
|
914
|
+
queuePressureStatus(peers: readonly string[]): QueueBackpressureStatus | undefined {
|
|
915
|
+
const policy = this.queuePolicy();
|
|
916
|
+
if (!policy) return undefined;
|
|
917
|
+
const all = [...new Set(peers)].filter(peer => /^[a-z][a-z0-9-]{0,31}$/.test(peer));
|
|
918
|
+
const bounded = all.slice(0, 4096), input = this.queueRouting(bounded, policy), now = input.now!;
|
|
919
|
+
const rows = Object.fromEntries(bounded.map(peer => {
|
|
920
|
+
const value = peerEntry(input.queues, peer), refusal = queueBackpressure(peer, policy, value, now);
|
|
921
|
+
const queued = value && Number.isSafeInteger(value.queued) && value.queued >= 0 ? value.queued : null;
|
|
922
|
+
const at = queued !== null && queued > 0 && typeof value?.oldestQueuedAt === "number" && Number.isSafeInteger(value.oldestQueuedAt) && value.oldestQueuedAt >= 0 && value.oldestQueuedAt <= now ? value.oldestQueuedAt : null;
|
|
923
|
+
return [peer, { queued, oldestQueuedAt: at, oldestAgeMs: at === null ? null : now - at, blocked: refusal !== undefined, ...(refusal ? { code: refusal.code, reason: refusal.reason } : {}) }];
|
|
924
|
+
}));
|
|
925
|
+
return { policy, peers: rows, ...(all.length > bounded.length ? { truncated: true as const } : {}) };
|
|
809
926
|
}
|
|
810
927
|
|
|
811
928
|
/** Peer health and roles the daemon feeds routing (issues #89, #90, #92): failing peers are skipped, held queues explained. */
|
|
812
|
-
private health() {
|
|
929
|
+
private health(ownerIsolation?: Readonly<Record<string, boolean>>) {
|
|
813
930
|
const failing = this.d.failing?.();
|
|
814
931
|
const held = this.d.held?.();
|
|
815
|
-
const workload = this.instanceRouting();
|
|
932
|
+
const workload = this.instanceRouting(ownerIsolation);
|
|
816
933
|
const peers = workload.instances ? [...new Set([...(this.d.adapterCatalog?.definitions ?? ADAPTERS).map(adapter => adapter.kind), ...Object.keys(workload.instances), ...this.d.bus.peers.keys()])] : (this.d.adapterCatalog?.definitions ?? ADAPTERS).map(adapter => adapter.kind);
|
|
817
|
-
return { ...workload, reviewPool: { pool: this.d.review?.pool ?? DEFAULT_REVIEW_POOL, rerotate: this.d.review?.rerotate, required: this.d.review?.pool !== undefined, ...(this.d.review?.same_family === "never" ? { sameFamily: "never" as const } : {}) }, ...(this.d.capabilities ? { capabilities: this.d.capabilities(), repository: this.d.repository } : {}), pullOnly: [...this.d.bus.peers.values()].filter(peer => (this.d.adapterCatalog?.definitionOf ?? definitionOf)(peer.id, workload.instances)?.attach?.pullOnlyForms?.length && peer.pullOnly).map(peer => peer.id), offRoster: peers.filter(peer => !this.d.bus.isRostered(peer)), ...(failing ? { failing } : {}), ...(held ? { held } : {}), ...(this.d.roles ? { roles: this.d.roles } : {}) };
|
|
934
|
+
return { ...workload, ...this.contextRouting(), ...this.queueRouting([...new Set([...peers, ...this.d.bus.peers.keys()])]), reviewPool: { pool: this.d.review?.pool ?? DEFAULT_REVIEW_POOL, rerotate: this.d.review?.rerotate, required: this.d.review?.pool !== undefined, ...(this.d.review?.same_family === "never" ? { sameFamily: "never" as const } : {}) }, ...(this.d.capabilities ? { capabilities: this.d.capabilities(), repository: this.d.repository } : {}), pullOnly: [...this.d.bus.peers.values()].filter(peer => (this.d.adapterCatalog?.definitionOf ?? definitionOf)(peer.id, workload.instances)?.attach?.pullOnlyForms?.length && peer.pullOnly).map(peer => peer.id), offRoster: peers.filter(peer => !this.d.bus.isRostered(peer)), ...(failing ? { failing } : {}), ...(held ? { held } : {}), ...(this.d.roles ? { roles: this.d.roles } : {}) };
|
|
818
935
|
}
|
|
819
936
|
|
|
820
937
|
private reviewHead(task: Task): string | undefined { return task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead; }
|
|
@@ -834,21 +951,30 @@ export class Tasks {
|
|
|
834
951
|
private poolReview(task: Task, refs?: TaskRefs): Assignment | undefined {
|
|
835
952
|
if (this.manuallyNamedReviewer(task) || this.isPii(task) || task.class === "review") return undefined;
|
|
836
953
|
const draft = { ...task, refs: { ...task.refs, ...cleanRefs(refs) } };
|
|
837
|
-
const
|
|
838
|
-
|
|
954
|
+
const weights = this.weights(task.class, draft);
|
|
955
|
+
const selection = assign(draft, this.states(), this.d.routing(), { ...weights, candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}) });
|
|
956
|
+
const heldSelection = task.reviewer === USER && task.history[this.reviewRequestRevision(task) - 1]?.queueReviewHeld === true;
|
|
957
|
+
const previous = task.reviewer && task.reviewer !== USER ? assign(draft, this.states(), this.d.routing(), { ...weights, candidates: [], namedReviewer: task.reviewer, ...(task.owner ? { notReviewer: task.owner } : {}) }) : undefined;
|
|
958
|
+
const queueRefused = previous?.refusals?.some(row => row.role === "reviewer" && row.peer === task.reviewer && isQueuePressureCode(row.code));
|
|
959
|
+
const newlyBlocked = selection.reviewBlocked && selection.refusals?.some(row => row.role === "reviewer" && isQueuePressureCode(row.code));
|
|
960
|
+
return selection.reviewSelection || queueRefused || newlyBlocked || heldSelection ? selection : undefined;
|
|
839
961
|
}
|
|
840
962
|
|
|
841
|
-
private reviewMetadata(assignment?: Assignment): ReviewerHistory {
|
|
963
|
+
private reviewMetadata(assignment?: Assignment, task?: Task): ReviewerHistory {
|
|
842
964
|
const choice = assignment?.reviewSelection;
|
|
843
965
|
const family = choice && assignment?.plannerFallback && assignment.reviewer ? (this.d.adapterCatalog?.familyOf ?? familyOf)(assignment.reviewer, this.d.instances?.()) : choice?.family;
|
|
844
966
|
const reason = choice && assignment?.plannerFallback
|
|
845
967
|
? assignment.sameFamilyFallback ? "same-family fallback" as const : "unavailable skip" as const
|
|
846
968
|
: choice?.reason;
|
|
847
|
-
|
|
969
|
+
const queueReviewHeld = task !== undefined && !this.isPii(task) && assignment?.reviewer === USER && assignment.reviewBlocked === true
|
|
970
|
+
&& assignment.refusals?.some(row => row.role === "reviewer" && isQueuePressureCode(row.code));
|
|
971
|
+
const queueReviewBinding = task ? this.queueReviewBinding(task) : undefined;
|
|
972
|
+
return { ...(family ? { reviewerFamily: family } : {}), ...(reason ? { reviewerReason: reason } : {}), ...(assignment?.reviewBlocked ? { reviewBlocked: true } : {}), ...(queueReviewHeld ? { queueReviewHeld: true as const, ...(queueReviewBinding !== undefined ? { queueReviewBinding } : {}) } : {}) };
|
|
848
973
|
}
|
|
849
974
|
|
|
850
975
|
/** Capability admission precedes explicit assignments/reservations, independent of readiness or transient peer health. */
|
|
851
976
|
private requireCapabilities(task: Parameters<typeof assign>[0], peer: PeerId, role: Exclude<TaskAssignmentRole, "planner"> = "owner", preEffect = true): void {
|
|
977
|
+
requireLocalTaskPeer(peer);
|
|
852
978
|
const instances = this.d.instances?.(), adapter = (this.d.adapterCatalog?.definitionOf ?? definitionOf)(peer, instances);
|
|
853
979
|
const declaredExtra = !!adapter && adapter.kind !== peer;
|
|
854
980
|
if ((!this.d.capabilities || this.isPii(task)) && !declaredExtra) return;
|
|
@@ -865,13 +991,14 @@ export class Tasks {
|
|
|
865
991
|
* Selection stays in assign(); task bindings only keep that selection while the holder remains eligible.
|
|
866
992
|
*/
|
|
867
993
|
private plannerHolders(): PeerId[] {
|
|
868
|
-
return Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => Array.isArray(roles) && roles.includes("planner") && this.d.bus.isRostered(peer)).map(([peer]) => peer).sort();
|
|
994
|
+
return Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => !isRemotePeerId(peer) && Array.isArray(roles) && roles.includes("planner") && this.d.bus.isRostered(peer)).map(([peer]) => peer).sort();
|
|
869
995
|
}
|
|
870
996
|
|
|
871
|
-
private weights(cls: TaskClass, task?: Task) {
|
|
997
|
+
private weights(cls: TaskClass, task?: Task, projectedHealth?: ReturnType<Tasks["health"]>) {
|
|
872
998
|
const now = Date.now();
|
|
873
999
|
const quota = this.d.quota?.();
|
|
874
|
-
|
|
1000
|
+
const health = projectedHealth ?? this.health(), clock = health.now ?? now;
|
|
1001
|
+
return { ...health, ...(task && this.hasPersonReservation(task) && task.reserved ? { queueReservedHold: task.reserved } : {}), planners: this.plannerHolders(), ...(task ? { reviewPool: this.reviewPoolOptions(task), ...(task.planner ? { keptPlanner: task.planner } : {}) } : {}), now: clock, demoted: this.demoted(cls, clock), reviews: this.reviewRecord(cls), ...(quota ? { quota } : {}), ...(this.d.review?.adaptive ? { adaptive: { min: this.d.review.min_reviews } } : {}) };
|
|
875
1002
|
}
|
|
876
1003
|
|
|
877
1004
|
/**
|
|
@@ -929,6 +1056,8 @@ export class Tasks {
|
|
|
929
1056
|
peers,
|
|
930
1057
|
observations: Object.fromEntries(peers.map((p) => [p, this.splitObservations(task.class, p, task.id)])),
|
|
931
1058
|
units: [unit, unit],
|
|
1059
|
+
...(this.d.splitMinObservations ? { minObservations: this.d.splitMinObservations() } : {}),
|
|
1060
|
+
...(this.d.splitMaxFailureShare ? { maxFailureShare: this.d.splitMaxFailureShare() } : {}),
|
|
932
1061
|
profiles: Object.fromEntries(peers.map((p) => [p, this.d.splitProfile?.(p)])),
|
|
933
1062
|
backlog: Object.fromEntries(peers.map((p) => [p, open.filter((t) => t.owner === p).length])),
|
|
934
1063
|
// Busy is taking the task in question only in the turn that task started (an owner goes busy as its task is
|
|
@@ -946,11 +1075,12 @@ export class Tasks {
|
|
|
946
1075
|
private contradict(failed: Task): void {
|
|
947
1076
|
if (this.isPii(failed)) return; // its places are left out everywhere else too
|
|
948
1077
|
const now = Date.now();
|
|
1078
|
+
const contradictionDays = this.d.contradictionWindowDays?.(), contradictionWindowMs = contradictionDays === undefined ? CONTRADICTION_WINDOW_MS : contradictionDays * 86_400_000;
|
|
949
1079
|
const mine = this.places(failed);
|
|
950
1080
|
for (const t of this.d.board.list("approved")) {
|
|
951
1081
|
if (t.id === failed.id || !t.owner || this.isPii(t)) continue;
|
|
952
1082
|
const approval = [...t.history].reverse().find((h) => h.event === "approved");
|
|
953
|
-
if (!approval || now - approval.at >
|
|
1083
|
+
if (!approval || now - approval.at > contradictionWindowMs || approval.by === USER) continue;
|
|
954
1084
|
const theirs = this.places(t);
|
|
955
1085
|
// Blame needs the same file or symbol: a directory or `.` would contradict every approval under it. Older rows
|
|
956
1086
|
// were stored as written, so both sides are compared in one spelling.
|
|
@@ -968,12 +1098,13 @@ export class Tasks {
|
|
|
968
1098
|
|
|
969
1099
|
/** A removed/off-roster binding falls back to legacy holders; a busy, paused or offline holder keeps authority. */
|
|
970
1100
|
taskPlanner(task: Pick<Task, "planner">): PeerId[] {
|
|
971
|
-
const holders = Object.keys(this.d.roles ?? {}).filter(peer => this.d.roles?.[peer]?.includes("planner")).sort();
|
|
1101
|
+
const holders = Object.keys(this.d.roles ?? {}).filter(peer => !isRemotePeerId(peer) && this.d.roles?.[peer]?.includes("planner")).sort();
|
|
972
1102
|
return task.planner && holders.includes(task.planner) && this.d.bus.isRostered(task.planner) ? [task.planner] : holders;
|
|
973
1103
|
}
|
|
974
1104
|
|
|
975
1105
|
/** Current planner rulings amend review context, never person-only plan-departure acceptance. */
|
|
976
1106
|
plannerRulings(task: Task, recipient: PeerId): { historyIndex: number; by: PeerId; at: number; note: string }[] {
|
|
1107
|
+
requireLocalTaskPeer(recipient);
|
|
977
1108
|
if (this.isPii(task)) return [];
|
|
978
1109
|
const boundary = task.history.findLastIndex(entry => entry.event === "accepted" || entry.event === "claimed");
|
|
979
1110
|
if (boundary < 0) return [];
|
|
@@ -991,13 +1122,15 @@ export class Tasks {
|
|
|
991
1122
|
}
|
|
992
1123
|
|
|
993
1124
|
private planningAuthority(by: PeerId, task?: Pick<Task, "planner">): void {
|
|
1125
|
+
requireLocalTaskPeer(by);
|
|
994
1126
|
if (by !== USER && (!this.d.roles?.[by]?.includes("planner") || task !== undefined && !this.taskPlanner(task).includes(by))) throw new PreEffectToolRefusal(`planning requires a person or configured planner role${task ? "; only the task's effective planner may act" : ""}`);
|
|
995
1127
|
}
|
|
996
1128
|
|
|
997
1129
|
/** #812 AC5: a peer holding the configured planner role. The conductor's task reads accept it; it grants no write. */
|
|
998
|
-
isConfiguredPlanner(peer: PeerId): boolean { return peer !== USER && peer !== HUB && !!this.d.roles?.[peer]?.includes("planner"); }
|
|
1130
|
+
isConfiguredPlanner(peer: PeerId): boolean { return !isRemotePeerId(peer) && peer !== USER && peer !== HUB && !!this.d.roles?.[peer]?.includes("planner"); }
|
|
999
1131
|
|
|
1000
1132
|
private plannerWriteAuthority(by: PeerId, task?: Pick<Task, "planner">): void {
|
|
1133
|
+
requireLocalTaskPeer(by);
|
|
1001
1134
|
if (by !== USER && this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("planner writes are unavailable during a private turn");
|
|
1002
1135
|
this.planningAuthority(by, task);
|
|
1003
1136
|
}
|
|
@@ -1063,6 +1196,7 @@ export class Tasks {
|
|
|
1063
1196
|
|
|
1064
1197
|
/** Host-observed task branch/root binding (#845), never a tool-call branch claim or an execution grant. */
|
|
1065
1198
|
bindShellBranch(peer: PeerId, id: number, binding: TaskShellBinding & { worktree: string }, current: () => boolean): boolean {
|
|
1199
|
+
requireLocalTaskPeer(peer);
|
|
1066
1200
|
try {
|
|
1067
1201
|
if (!Number.isSafeInteger(id) || id < 1 || !taskShellBindingValid(binding) || !this.publicMetadataText(binding.branch)
|
|
1068
1202
|
|| !current() || this.d.privateTurn?.(peer)) return false;
|
|
@@ -1143,7 +1277,7 @@ export class Tasks {
|
|
|
1143
1277
|
/** The top card explicitly reserved for `peer` that this planner may pull now; the notice and the opt-in pull read the same rule. `pullable` keeps the staged backlog cards only. */
|
|
1144
1278
|
private topReservedReady(peer: PeerId, planner?: PeerId, pullable = false): Task | undefined {
|
|
1145
1279
|
return this.d.board.list().filter(card => (pullable ? card.state === "backlog" : ["backlog", "proposed"].includes(card.state)) && !card.owner && card.reserved === peer
|
|
1146
|
-
&& (planner === undefined || this.taskPlanner(card).includes(planner)) && !(pullable && this.isPii(card)) && !card.pinned && taskCanPlan(card) && !this.waitsFor(card).length && !this.excluded(card).includes(peer)
|
|
1280
|
+
&& (planner === undefined || this.taskPlanner(card).includes(planner)) && !(pullable && this.isPii(card)) && !card.pinned && !card.draft && taskCanPlan(card) && !this.waitsFor(card).length && !this.excluded(card).includes(peer)
|
|
1147
1281
|
&& (card.release == null || ["planning", "active"].includes(this.d.board.release(card.release)?.state ?? ""))).sort(this.readyOrder)[0];
|
|
1148
1282
|
}
|
|
1149
1283
|
|
|
@@ -1207,6 +1341,7 @@ export class Tasks {
|
|
|
1207
1341
|
plannerRefusal(task: Task): string {
|
|
1208
1342
|
const parts: string[] = [];
|
|
1209
1343
|
const excluded = [...new Set(this.excluded(task))];
|
|
1344
|
+
const pressure = this.queueRouting(Object.keys(this.states()));
|
|
1210
1345
|
if (excluded.length) parts.push(`${excluded.join(", ")} declined or was escalated away from this task for good; only a person's ahub task assign lifts that`);
|
|
1211
1346
|
for (const peer of Object.keys(this.states())) {
|
|
1212
1347
|
if (excluded.includes(peer)) continue;
|
|
@@ -1214,14 +1349,16 @@ export class Tasks {
|
|
|
1214
1349
|
if (wip >= limit) parts.push(`${peer} WIP full (${wip}/${limit}); no eligible planner WIP capacity`);
|
|
1215
1350
|
else if (queue.heldBy) parts.push(`${peer} has a held queue (a delivery is held; resolve it with ahub queue list)`);
|
|
1216
1351
|
else if (queue.needsReview > 0) parts.push(`${peer} has ${queue.needsReview} delivery(ies) in needs_review`);
|
|
1352
|
+
const refusal = queueBackpressure(peer, pressure.queueBackpressure, peerEntry(pressure.queues, peer), pressure.now ?? NaN);
|
|
1353
|
+
if (refusal) parts.push(`${peer} ${refusal.reason}`);
|
|
1217
1354
|
}
|
|
1218
1355
|
return parts.length ? `no peer can take the planner pull: ${parts.join("; ")}` : "no peer has eligible planner WIP capacity";
|
|
1219
1356
|
}
|
|
1220
|
-
private plannerAssignment(task: Task, reservedOnly = false, routing: Routing = this.d.routing()): Assignment {
|
|
1357
|
+
private plannerAssignment(task: Task, reservedOnly = false, routing: Routing = this.d.routing(), queueOverride?: "owner", projectedHealth?: ReturnType<Tasks["health"]>): Assignment {
|
|
1221
1358
|
const states = this.states();
|
|
1222
1359
|
const full = this.plannerFull(task);
|
|
1223
1360
|
// Preview the To Do transition without routing or offering the persisted backlog row.
|
|
1224
|
-
return assign({ ...task, state: "proposed" }, states, routing, { exclude: this.excluded(task), excludeOwners: full, ...(reservedOnly && task.reserved ? { candidates: [task.reserved], namedOwner: task.reserved } : {}), waitsFor: this.waitsFor(task), ...this.weights(task.class, task) });
|
|
1361
|
+
return assign({ ...task, state: "proposed" }, states, routing, { exclude: this.excluded(task), excludeOwners: full, ...(reservedOnly && task.reserved ? { candidates: [task.reserved], namedOwner: task.reserved } : {}), waitsFor: this.waitsFor(task), ...this.weights(task.class, task, projectedHealth), ...(queueOverride ? { queueOverride, contextOverride: true } : {}) });
|
|
1225
1362
|
}
|
|
1226
1363
|
|
|
1227
1364
|
async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }, opts: { onBehalfOf?: PeerId; guard?: () => void } = {}): Promise<Task> {
|
|
@@ -1229,13 +1366,18 @@ export class Tasks {
|
|
|
1229
1366
|
// #785 P1c: `by` is the actor the history records; the hub's opt-in auto pull names the planner it acts for, and that planner's authority is what is checked.
|
|
1230
1367
|
if (opts.onBehalfOf !== undefined && by !== HUB) throw new PreEffectToolRefusal("only the hub pulls on a planner's behalf");
|
|
1231
1368
|
const planner = opts.onBehalfOf ?? by;
|
|
1369
|
+
let queueAssignment: { current: () => void } | undefined;
|
|
1232
1370
|
// `opts.guard` (the auto pull's own admission, #785 P1c) throws a PreEffectToolRefusal once the pull is no longer wanted; it runs wherever authority is rechecked, so it
|
|
1233
1371
|
// also runs after the awaited PII screen and right before the assignment write, where a setting turned off or a recovery that began meanwhile must still stop it.
|
|
1234
|
-
const authorized = () => { this.planningAuthority(planner); opts.guard?.(); };
|
|
1372
|
+
const authorized = () => { this.planningAuthority(planner); opts.guard?.(); queueAssignment?.current(); };
|
|
1235
1373
|
authorized();
|
|
1236
1374
|
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");
|
|
1375
|
+
queueAssignment = by !== HUB && opts.onBehalfOf === undefined && this.d.board.get(input.id)?.reserved
|
|
1376
|
+
? this.explicitQueueAssignment(by, "owner", true) : undefined;
|
|
1377
|
+
queueAssignment?.current();
|
|
1237
1378
|
const validate = () => {
|
|
1238
1379
|
const task = this.d.board.get(input.id);
|
|
1380
|
+
if (task?.draft) throw new PreEffectToolRefusal(`task #${task.id} is an intake draft; the person approves it first`);
|
|
1239
1381
|
if (!task || (task.planningRevision ?? 0) !== input.revision || task.state !== "backlog" || task.owner || !taskCanPlan(task) || task.pinned) throw new PreEffectToolRefusal("planner pull changed, pinned or already started");
|
|
1240
1382
|
this.planningAuthority(planner, task);
|
|
1241
1383
|
const release = task.release == null ? undefined : this.d.board.release(task.release);
|
|
@@ -1253,7 +1395,7 @@ export class Tasks {
|
|
|
1253
1395
|
})) throw new PreEffectToolRefusal("a person's P0 backlog order is retained");
|
|
1254
1396
|
}
|
|
1255
1397
|
if (task.reserved && this.excluded(task).includes(task.reserved)) throw new PreEffectToolRefusal(this.plannerRefusal(task));
|
|
1256
|
-
const assignment = this.plannerAssignment(task, !!task.reserved);
|
|
1398
|
+
const assignment = this.plannerAssignment(task, !!task.reserved, this.d.routing(), queueAssignment ? "owner" : undefined);
|
|
1257
1399
|
if (!assignment.owner) {
|
|
1258
1400
|
const refusal = task.reserved ? `reserved peer ${task.reserved} cannot take the planner pull: ${assignment.refusals?.find(row => row.role === "owner" && row.peer === task.reserved)?.reason ?? "not eligible"}; ${this.plannerRefusal(task)}` : this.plannerRefusal(task);
|
|
1259
1401
|
throw new PreEffectToolRefusal(`${this.hasPersonReservation(task) ? "a person's reservation is retained; " : ""}${refusal}`);
|
|
@@ -1264,7 +1406,7 @@ export class Tasks {
|
|
|
1264
1406
|
authorized();
|
|
1265
1407
|
const current = validate();
|
|
1266
1408
|
if (current.task.reserved) {
|
|
1267
|
-
return this.assignOwner(current.task, by, { candidates: [current.owner], namedOwner: current.owner, planner: true, refuseNamed: "pre",
|
|
1409
|
+
return this.assignOwner(current.task, by, { candidates: [current.owner], namedOwner: current.owner, planner: true, refuseNamed: "pre", ...(queueAssignment ? { queueOverride: "owner" as const, current: queueAssignment.current } : {}),
|
|
1268
1410
|
plannerPull: { revision: input.revision, note: input.reason.trim(), withheld, guard: () => {
|
|
1269
1411
|
authorized();
|
|
1270
1412
|
const checked = validate();
|
|
@@ -1286,7 +1428,7 @@ export class Tasks {
|
|
|
1286
1428
|
const pending = this.plannerBacklog.get(peer) ?? new Set<number>();
|
|
1287
1429
|
for (const id of ids) {
|
|
1288
1430
|
const task = this.d.board.get(id);
|
|
1289
|
-
if (task?.state === "backlog" && this.taskPlanner(task).includes(peer)) pending.add(id);
|
|
1431
|
+
if (task?.state === "backlog" && !task.draft && this.taskPlanner(task).includes(peer)) pending.add(id);
|
|
1290
1432
|
}
|
|
1291
1433
|
if (pending.size) this.plannerBacklog.set(peer, pending);
|
|
1292
1434
|
}
|
|
@@ -1327,11 +1469,13 @@ export class Tasks {
|
|
|
1327
1469
|
scan.revision = revision;
|
|
1328
1470
|
}
|
|
1329
1471
|
const now = this.d.now?.() ?? Date.now();
|
|
1472
|
+
const configuredFyiMin = this.d.groomFyiWindowMin?.();
|
|
1473
|
+
const fyiWindowMs = configuredFyiMin === undefined ? GROOM_FYI_WINDOW_MS : configuredFyiMin * 60_000;
|
|
1330
1474
|
for (const peer of planners) {
|
|
1331
1475
|
const told = scan.sent.get(peer), pending = scan.pending.get(peer);
|
|
1332
1476
|
const review = told === undefined || pending?.review === true;
|
|
1333
1477
|
if (!review && !pending?.other) continue;
|
|
1334
|
-
if (!review && now - told!.fyiAt <
|
|
1478
|
+
if (!review && now - told!.fyiAt < fyiWindowMs) { scan.pending.delete(peer); continue; }
|
|
1335
1479
|
this.releaseNotice(`could not notify planner ${peer} about grooming`, () => {
|
|
1336
1480
|
if (review) this.sendGroomNotice(peer, revision);
|
|
1337
1481
|
else this.d.bus.publish(newEnvelope(HUB, `${GROOM_NOTICE_PREFIX} (revision ${revision}): state moves, reviews or assignments only. No plan review is needed.`, { to: [peer], kind: "task", priority: "fyi" }));
|
|
@@ -1351,7 +1495,7 @@ export class Tasks {
|
|
|
1351
1495
|
|
|
1352
1496
|
/** One bounded ids-only batch per attached planner; failed or evicted copies stay pending. */
|
|
1353
1497
|
flushPlannerNotices(): number {
|
|
1354
|
-
const configured = Object.entries(this.d.roles ?? {}).filter(([, roles]) => roles.includes("planner")).map(([peer]) => peer);
|
|
1498
|
+
const configured = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => !isRemotePeerId(peer) && roles.includes("planner")).map(([peer]) => peer);
|
|
1355
1499
|
for (const peer of this.knownPlanners) if (!configured.includes(peer)) {
|
|
1356
1500
|
this.knownPlanners.delete(peer); this.plannerBacklog.delete(peer); this.plannerBlockedQueue.delete(peer);
|
|
1357
1501
|
this.groomScan.pending.delete(peer); this.groomScan.sent.delete(peer);
|
|
@@ -1542,7 +1686,7 @@ export class Tasks {
|
|
|
1542
1686
|
}
|
|
1543
1687
|
|
|
1544
1688
|
async importGitHub(by: PeerId, input: GitHubImportSelection, run?: SdlcGh): Promise<{ imported: number[]; existing: number[]; partial: boolean; complete: boolean; unconfirmed?: true }> {
|
|
1545
|
-
if (by
|
|
1689
|
+
if (!this.personOrConductor(by)) throw new PreEffectToolRefusal("GitHub import is a person console command");
|
|
1546
1690
|
const deadline = performance.now() + 20_000;
|
|
1547
1691
|
const page = await readGitHubImport(input, run ?? ((args, timeout, _cwd, bound) => sdlcGh(args, timeout, this.d.cwd, bound)));
|
|
1548
1692
|
const imported: number[] = [], existing: number[] = [];
|
|
@@ -1554,7 +1698,7 @@ export class Tasks {
|
|
|
1554
1698
|
if (prior) { existing.push(prior.id); continue; }
|
|
1555
1699
|
const classes = profileData(this.d.profileData ?? this.d.profile ?? "dev").classes;
|
|
1556
1700
|
const cls = Object.keys(classes).find(name => classes[name] === "implement") ?? Object.keys(classes)[0];
|
|
1557
|
-
const task = await this.propose(
|
|
1701
|
+
const task = await this.propose(by, { title: issue.title, detail: issue.detail, class: cls, backlog: true }, issue.source, () => { created = true; });
|
|
1558
1702
|
(created ? imported : existing).push(task.id);
|
|
1559
1703
|
if (performance.now() >= deadline) return { imported, existing, partial: true, complete: false };
|
|
1560
1704
|
} catch {
|
|
@@ -1570,8 +1714,10 @@ export class Tasks {
|
|
|
1570
1714
|
return { imported, existing, partial: page.partial, complete: !page.partial };
|
|
1571
1715
|
}
|
|
1572
1716
|
|
|
1573
|
-
async splitBacklog(by: PeerId, input: { id: number; revision: number; parts: unknown; reason: string }): Promise<{ parent: Task; children: Task[] }> {
|
|
1574
|
-
|
|
1717
|
+
async splitBacklog(by: PeerId, input: { id: number; revision: number; parts: unknown; reason: string }, opts: { intake?: true } = {}): Promise<{ parent: Task; children: Task[] }> {
|
|
1718
|
+
// #334: the hub writes an intake draft's own model split; no other write by the hub passes as a planner.
|
|
1719
|
+
const authority = (task?: Pick<Task, "planner">) => { if (!(opts.intake && by === HUB)) this.planningAuthority(by, task); };
|
|
1720
|
+
authority();
|
|
1575
1721
|
const { id, revision } = input;
|
|
1576
1722
|
if (!Number.isSafeInteger(id) || id <= 0 || !Number.isSafeInteger(revision) || revision < 0 || typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("split needs an id, revision and bounded reason");
|
|
1577
1723
|
const reason = input.reason.trim();
|
|
@@ -1584,7 +1730,9 @@ export class Tasks {
|
|
|
1584
1730
|
if (!parent || parent.state !== "backlog" || parent.owner || taskHasStarted(parent) || (parent.planningRevision ?? 0) !== revision || by !== USER && parent.pinned) throw new PreEffectToolRefusal("split changed, pinned or already started");
|
|
1585
1731
|
const release = parent.release == null ? undefined : this.d.board.release(parent.release);
|
|
1586
1732
|
if (parent.release != null && (!release || ["shipped", "dropped"].includes(release.state) || by !== USER && release.state === "frozen")) throw new PreEffectToolRefusal("split release scope is frozen or final");
|
|
1587
|
-
|
|
1733
|
+
authority(parent);
|
|
1734
|
+
// #334: the hub writes a split only into a draft that is still a draft. Both validations run (before and after the awaited screen), and the board asks again in its transaction.
|
|
1735
|
+
if (opts.intake && by === HUB && !parent.draft) throw new PreEffectToolRefusal("split changed, pinned or already started");
|
|
1588
1736
|
const profile = parent.profile ?? "dev";
|
|
1589
1737
|
const drafts = rows.map((part, index) => {
|
|
1590
1738
|
if (!part || typeof part !== "object" || Array.isArray(part) || Object.keys(part).some(key => !["title", "detail", "class", "refs", "plan", "after", "afterParts"].includes(key)) || typeof part.title !== "string" || !part.title.trim() || part.title.length > 300 || part.detail !== undefined && (typeof part.detail !== "string" || part.detail.length > 8000) || part.class !== undefined && typeof part.class !== "string") throw new PreEffectToolRefusal("invalid split part");
|
|
@@ -1619,7 +1767,7 @@ export class Tasks {
|
|
|
1619
1767
|
let screened: Awaited<typeof attempt>;
|
|
1620
1768
|
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); })]); }
|
|
1621
1769
|
finally { if (deadline !== undefined) clearTimeout(deadline); }
|
|
1622
|
-
|
|
1770
|
+
authority(); // #455: a person may have moved the planner while the screen ran
|
|
1623
1771
|
const [screenedVerdicts, withheld] = screened, current = validate();
|
|
1624
1772
|
const finalSignals = current.drafts.map(part => this.signalsFor({ ...part, detail: [part.detail, planText(part.plan)].join("\n") }));
|
|
1625
1773
|
const deterministicPrivate = finalSignals.map((signals, index) => initialPrivate[index] || this.isPii({ signals }));
|
|
@@ -1627,6 +1775,8 @@ export class Tasks {
|
|
|
1627
1775
|
const inherited = current.drafts.map((_part, index) => (initialParentPrivate || this.isPii(current.parent)) && !deterministicPrivate[index]);
|
|
1628
1776
|
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"] : [])])] }));
|
|
1629
1777
|
for (const draft of drafts) this.warmRepository(draft);
|
|
1778
|
+
// #334: the hub's intake split is only for a draft; the board asks for it again inside its transaction (a flag set for exactly this write, never left on).
|
|
1779
|
+
this.d.board.requireDraftForNextSplit = opts.intake === true && by === HUB;
|
|
1630
1780
|
const result = this.d.board.splitBacklog(id, by, revision, drafts, reason, withheld || initialReasonPrivate || this.isPii(current.parent) || !this.nameable(reason));
|
|
1631
1781
|
if (this.d.coordination) {
|
|
1632
1782
|
const mode = this.d.coordination();
|
|
@@ -1640,6 +1790,92 @@ export class Tasks {
|
|
|
1640
1790
|
return result;
|
|
1641
1791
|
}
|
|
1642
1792
|
|
|
1793
|
+
/**
|
|
1794
|
+
* The draft and every open draft card split from it, at any depth, parent first. A planner may split an intake child again before approval, and
|
|
1795
|
+
* the grandchildren inherit the mark, so the walk follows `deps` through draft cards only: a dependency on ordinary work is never followed.
|
|
1796
|
+
*/
|
|
1797
|
+
private intakeFamily(id: number): Task[] {
|
|
1798
|
+
const root = this.d.board.get(id);
|
|
1799
|
+
if (!root || !root.draft || root.state !== "backlog") throw new PreEffectToolRefusal(`task #${id} is not an open intake draft`);
|
|
1800
|
+
const family: Task[] = [root], seen = new Set([root.id]);
|
|
1801
|
+
for (let next = 0; next < family.length; next++) {
|
|
1802
|
+
for (const dep of family[next]!.deps ?? []) {
|
|
1803
|
+
if (seen.has(dep)) continue;
|
|
1804
|
+
const child = this.d.board.get(dep);
|
|
1805
|
+
if (!child || !child.draft || child.state !== "backlog") continue;
|
|
1806
|
+
seen.add(dep); family.push(child);
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
return family;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
/**
|
|
1813
|
+
* #334 (#511a): the person's request becomes one parent draft, a backlog card that nothing offers or pulls. A planner is asked to split it with
|
|
1814
|
+
* `hub_backlog_split`; with none, or for a request that is PII, the hub's model proposes the split and the hub writes it. A split that does not
|
|
1815
|
+
* validate leaves the draft whole, with the reason on its history.
|
|
1816
|
+
*/
|
|
1817
|
+
async intake(by: PeerId, input: { text: unknown; sources?: unknown }): Promise<{ draft: Task; children: Task[]; split: "planner" | "hub" | "unsplit"; reason?: string }> {
|
|
1818
|
+
if (by !== USER) throw new PreEffectToolRefusal("intake is a console command");
|
|
1819
|
+
const text = typeof input.text === "string" ? input.text.trim() : "";
|
|
1820
|
+
if (!text || text.length > INTAKE_MAX_TEXT) throw new PreEffectToolRefusal(`intake needs a request of 1 to ${INTAKE_MAX_TEXT} characters`);
|
|
1821
|
+
const given = input.sources ?? [];
|
|
1822
|
+
if (!Array.isArray(given) || given.length > INTAKE_MAX_SOURCES || given.some(id => typeof id !== "string" || !SOURCE_ID.test(id)) || new Set(given).size !== given.length) throw new PreEffectToolRefusal(`sources: at most ${INTAKE_MAX_SOURCES} distinct src:<16 hex> ids`);
|
|
1823
|
+
const sources = given as string[];
|
|
1824
|
+
for (const id of sources) if (!this.d.intake?.sourceKnown(id)) throw new PreEffectToolRefusal(`unknown source ${id}; register it with ahub source add`);
|
|
1825
|
+
const title = `Intake: ${text.split("\n")[0]!.replace(/\s+/g, " ").trim().slice(0, 80)}`;
|
|
1826
|
+
let draft = await this.propose(USER, { title, detail: intakeDetail(text, sources), class: "plan", backlog: true, draft: true });
|
|
1827
|
+
this.incomingBacklog.delete(draft.id);
|
|
1828
|
+
const priv = this.isPii(draft);
|
|
1829
|
+
const planner = priv ? undefined : this.taskPlanner(draft).find(peer => this.d.bus.isRostered(peer) && this.d.bus.peers.has(peer) && this.d.bus.stateOf(peer) !== "offline");
|
|
1830
|
+
if (planner) {
|
|
1831
|
+
const body = plannerRequestBody({ id: draft.id, revision: draft.planningRevision ?? 0, text: parseIntakeDetail(String(this.publicView(draft).detail)).text, sources });
|
|
1832
|
+
try {
|
|
1833
|
+
if (this.d.bus.publish(newEnvelope(HUB, sanitize(body), { to: [planner], kind: "task", refs: { task: String(draft.id), taskRecipient: "planner" } })).includes(planner)) return { draft, children: [], split: "planner" };
|
|
1834
|
+
} catch { /* the planner is not reachable: the hub model takes it */ }
|
|
1835
|
+
}
|
|
1836
|
+
return { ...(await this.intakeByHub(draft, text, sources, priv)), draft: this.d.board.get(draft.id) ?? draft };
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
/** The hub model's split of a draft, validated against the profile's `hub_backlog_split` parts schema and written through `splitBacklog`. */
|
|
1840
|
+
private async intakeByHub(draft: Task, text: string, sources: string[], priv: boolean): Promise<{ children: Task[]; split: "hub" | "unsplit"; reason?: string }> {
|
|
1841
|
+
const unsplit = (reason: string) => { this.d.board.update(draft.id, HUB, "intake unsplit", {}, reason); return { children: [], split: "unsplit" as const, reason }; };
|
|
1842
|
+
if (!this.d.intake) return unsplit("no planner is attached and the hub has no model for a split");
|
|
1843
|
+
// A request that is PII goes only to a model reached on campus; without one it stays whole for the person.
|
|
1844
|
+
if (priv && !(await this.d.triage?.onCampus().catch(() => false))) return unsplit("the request is PII and no on-prem model was reached");
|
|
1845
|
+
let raw: string | undefined;
|
|
1846
|
+
try { raw = await this.d.intake.split(intakeSystemPrompt(Object.keys(profileData(this.d.profileData ?? this.d.profile ?? "dev").classes), this.d.intake.partsSchema()), intakeUserMessage(text, sources), { campusOnly: priv }); } catch { raw = undefined; }
|
|
1847
|
+
const parsed = parseIntakeSplit(raw, this.d.intake.partsSchema(), sources);
|
|
1848
|
+
if ("error" in parsed) return unsplit(parsed.error);
|
|
1849
|
+
const current = this.d.board.get(draft.id);
|
|
1850
|
+
if (!current || !current.draft || current.state !== "backlog") return unsplit("the draft changed while the model answered");
|
|
1851
|
+
try {
|
|
1852
|
+
const result = await this.splitBacklog(HUB, { id: draft.id, revision: current.planningRevision ?? 0, parts: parsed.parts, reason: "intake: split by the hub model" }, { intake: true });
|
|
1853
|
+
return { children: result.children, split: "hub" };
|
|
1854
|
+
} catch (error) { return unsplit(`the split was refused: ${error instanceof PreEffectToolRefusal ? sanitize(error.message).slice(0, 200) : "the board refused it"}`); }
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
/** #334: the person approves a draft; it and its children leave draft and follow the normal backlog pull. */
|
|
1858
|
+
async approveIntake(by: PeerId, id: unknown): Promise<Task[]> {
|
|
1859
|
+
if (!this.personOrConductor(by)) throw new PreEffectToolRefusal("intake approval is a console command");
|
|
1860
|
+
if (typeof id !== "number" || !Number.isSafeInteger(id) || id <= 0) throw new PreEffectToolRefusal("id must be a positive task number");
|
|
1861
|
+
const family = this.intakeFamily(id);
|
|
1862
|
+
let approved: Task[];
|
|
1863
|
+
try { approved = this.d.board.approveDrafts(family.map(task => task.id), by, by === USER ? "intake approved by the person" : `intake approved by the conductor ${by}`); }
|
|
1864
|
+
catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
|
|
1865
|
+
for (const task of approved) this.incomingBacklog.add(task.id);
|
|
1866
|
+
this.releaseNotice(`intake draft #${id} approved: ${approved.length} backlog card${approved.length === 1 ? "" : "s"}`);
|
|
1867
|
+
return approved;
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
/** #334: the person discards a draft; it and its children are cancelled through `cancel`. */
|
|
1871
|
+
async discardIntake(by: PeerId, id: unknown): Promise<Task[]> {
|
|
1872
|
+
if (by !== USER) throw new PreEffectToolRefusal("intake discard is a console command");
|
|
1873
|
+
if (typeof id !== "number" || !Number.isSafeInteger(id) || id <= 0) throw new PreEffectToolRefusal("id must be a positive task number");
|
|
1874
|
+
const cancelled: Task[] = [];
|
|
1875
|
+
for (const task of this.intakeFamily(id)) cancelled.push(await this.cancel(task.id, USER, { reason: "intake draft discarded by the person" }));
|
|
1876
|
+
return cancelled;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1643
1879
|
/** Release assignment changes no execution role, dependency, reservation or state. */
|
|
1644
1880
|
async assignRelease(by: PeerId, input: { id: number; revision: number; release: number | null; reason: string }): Promise<Task> {
|
|
1645
1881
|
this.plannerWriteAuthority(by);
|
|
@@ -1789,12 +2025,14 @@ export class Tasks {
|
|
|
1789
2025
|
return updated; // a failed readback may follow a durable release write
|
|
1790
2026
|
}
|
|
1791
2027
|
|
|
1792
|
-
async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; role?: unknown; refs?: TaskRefs; plan?: TaskPlan; owner?: PeerId; after?: unknown; urgent?: unknown; backlog?: unknown; goal?: unknown }, external?: GitHubSource, created?: () => void): Promise<Task> {
|
|
1793
|
-
const requestCheck = this.requestCheck(by);
|
|
2028
|
+
async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; role?: unknown; refs?: TaskRefs; plan?: TaskPlan; owner?: PeerId; after?: unknown; urgent?: unknown; backlog?: unknown; draft?: unknown; goal?: unknown }, external?: GitHubSource, created?: () => void, fence?: () => string | undefined): Promise<Task> {
|
|
2029
|
+
const requestCheck = this.requestCheck(by, fence);
|
|
2030
|
+
requireLocalTaskPeer(input.owner);
|
|
1794
2031
|
external = external ? { ...external } : undefined;
|
|
1795
|
-
if (external && (by
|
|
2032
|
+
if (external && (!this.personOrConductor(by) || input.backlog !== true)) throw new PreEffectToolRefusal("external intake requires person-owned backlog staging");
|
|
1796
2033
|
if (input.backlog !== undefined && typeof input.backlog !== "boolean") throw new PreEffectToolRefusal("backlog must be a boolean");
|
|
1797
2034
|
const backlog = input.backlog === true;
|
|
2035
|
+
if (input.draft !== undefined && (input.draft !== true || !backlog || by !== USER)) throw new PreEffectToolRefusal("only the person's intake stages a backlog draft");
|
|
1798
2036
|
const claiming = !backlog && input.owner === by;
|
|
1799
2037
|
if (claiming) {
|
|
1800
2038
|
this.acceptanceCapacity({ id: -1, owner: by, state: "proposed" });
|
|
@@ -1864,7 +2102,7 @@ export class Tasks {
|
|
|
1864
2102
|
this.warmRepository({ refs: draft.refs, plan });
|
|
1865
2103
|
const repositoryMismatch = !this.isPii({ signals }) && this.repositories.mismatch(draft.refs, plan);
|
|
1866
2104
|
requestCheck();
|
|
1867
|
-
let task = this.d.board.propose(by, { ...draft, plan, ...(external ? { external } : {}), ...(deps.length ? { deps } : {}), ...(reserved ? { reserved } : {}), signals, ...(backlog ? { backlog: true } : {}) });
|
|
2105
|
+
let task = this.d.board.propose(by, { ...draft, plan, ...(external ? { external } : {}), ...(deps.length ? { deps } : {}), ...(reserved ? { reserved } : {}), signals, ...(backlog ? { backlog: true } : {}), ...(input.draft === true ? { draft: true } : {}) });
|
|
1868
2106
|
if (repositoryMismatch) this.releaseNotice(`task #${task.id}: refs and plan name different repositories; check refs.repo before editing`);
|
|
1869
2107
|
if (this.d.coordination) this.coordinationModes.set(task.id, this.d.coordination());
|
|
1870
2108
|
created?.();
|
|
@@ -1934,6 +2172,7 @@ export class Tasks {
|
|
|
1934
2172
|
if (!Number.isSafeInteger(n) || n <= 0) throw new PreEffectToolRefusal("id must be a positive task number");
|
|
1935
2173
|
const task = this.d.board.get(n);
|
|
1936
2174
|
if (!task) throw new PreEffectToolRefusal(`no task #${n}`);
|
|
2175
|
+
if (pull && task.draft) throw new PreEffectToolRefusal(`task #${n} is an intake draft; approve it with ahub intake approve ${n} first`);
|
|
1937
2176
|
if (task.state !== (pull ? "backlog" : "proposed") || task.owner) throw new PreEffectToolRefusal(`task #${n}: ${pull ? "only backlog may be pulled" : "only ownerless proposed work may return to backlog"}`);
|
|
1938
2177
|
if (!pull && taskHasStarted(task)) throw new PreEffectToolRefusal(`task #${n}: started work cannot return to backlog`);
|
|
1939
2178
|
const pulled = task.history.findLast(entry => entry.event === "backlog pulled" || entry.event === "backlog returned");
|
|
@@ -1977,7 +2216,8 @@ export class Tasks {
|
|
|
1977
2216
|
return t.state === "proposed" && !t.owner && !t.history.some((h) => h.owner) && t.signals.includes("pii") && last.startsWith("pii: unknown") && last !== "pii: unknown, too long";
|
|
1978
2217
|
};
|
|
1979
2218
|
const tries = (t: Task) => this.rescreens.get(t.id) ?? 0;
|
|
1980
|
-
const
|
|
2219
|
+
const rescreenMax = this.d.rescreenMax?.() ?? RESCREEN_MAX;
|
|
2220
|
+
const task = this.d.board.list("proposed").filter((t) => waiting(t) && tries(t) < rescreenMax).sort((a, b) => tries(a) - tries(b))[0];
|
|
1981
2221
|
if (!task) return;
|
|
1982
2222
|
this.rescreens.set(task.id, (this.rescreens.get(task.id) ?? 0) + 1);
|
|
1983
2223
|
const text = screenText(task);
|
|
@@ -2117,6 +2357,7 @@ export class Tasks {
|
|
|
2117
2357
|
* recipient, and carries `completedFor` so the bus replaces an earlier queued notice for the same `done` task and recipient.
|
|
2118
2358
|
*/
|
|
2119
2359
|
whileOpenFor(to: PeerId, done: number, about: number[], body: string): void {
|
|
2360
|
+
requireLocalTaskPeer(to);
|
|
2120
2361
|
const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(about[0]), taskRecipient: "owner", completedFor: done } });
|
|
2121
2362
|
this.conditional.set(env.id, { peer: to, task: about[0]!, tasks: about, states: OPEN });
|
|
2122
2363
|
if (this.conditional.size > 1024) this.conditional.delete(this.conditional.keys().next().value as string);
|
|
@@ -2124,6 +2365,7 @@ export class Tasks {
|
|
|
2124
2365
|
}
|
|
2125
2366
|
|
|
2126
2367
|
whileOpen(to: PeerId, task: number, body: string, states: Task["state"][] = OPEN): void {
|
|
2368
|
+
requireLocalTaskPeer(to);
|
|
2127
2369
|
const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(task), taskRecipient: "owner" } });
|
|
2128
2370
|
this.conditional.set(env.id, { peer: to, task, states });
|
|
2129
2371
|
if (this.conditional.size > 1024) this.conditional.delete(this.conditional.keys().next().value as string);
|
|
@@ -2262,6 +2504,7 @@ export class Tasks {
|
|
|
2262
2504
|
* Only when some other peer can take a task: with nobody to hand it to, it stays with its owner.
|
|
2263
2505
|
*/
|
|
2264
2506
|
async releaseFromGone(peer: PeerId, minutes: number): Promise<{ id: number; title: string; to: PeerId | null }[]> {
|
|
2507
|
+
requireLocalTaskPeer(peer);
|
|
2265
2508
|
const moved: { id: number; title: string; to: PeerId | null }[] = [];
|
|
2266
2509
|
const why = `owner ${peer} offline for ${minutes} min`;
|
|
2267
2510
|
const ids = this.d.board.list().filter((t) => t.owner === peer && OPEN.includes(t.state)).map((t) => t.id);
|
|
@@ -2334,11 +2577,12 @@ export class Tasks {
|
|
|
2334
2577
|
}
|
|
2335
2578
|
}
|
|
2336
2579
|
|
|
2337
|
-
/**
|
|
2580
|
+
/** Authorized manual owner removal sticks until a later explicit assignment/reservation, across hub runs. */
|
|
2338
2581
|
private personUnassigned(task: Task): boolean {
|
|
2339
|
-
const
|
|
2582
|
+
const manual = (h: HistoryEntry) => h.by === USER || h.conductorManual === true;
|
|
2583
|
+
const removed = task.history.findLastIndex((h) => h.event === "unassigned" && manual(h));
|
|
2340
2584
|
if (removed < 0) return false;
|
|
2341
|
-
return !task.history.slice(removed + 1).some((h) => h
|
|
2585
|
+
return !task.history.slice(removed + 1).some((h) => manual(h) && ["assigned", "reassigned", "escalated", "reserved", "backlog pulled"].includes(h.event));
|
|
2342
2586
|
}
|
|
2343
2587
|
|
|
2344
2588
|
/**
|
|
@@ -2390,7 +2634,7 @@ export class Tasks {
|
|
|
2390
2634
|
}
|
|
2391
2635
|
|
|
2392
2636
|
/** `override`: a person's explicit assign, which past refusals do not block and which drops an agent's reservation (#207). */
|
|
2393
|
-
private async assignOwner(task: Task, by: PeerId, opts: { current?: () => void; candidates?: PeerId[]; namedOwner?: PeerId; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean; roster?: boolean; planner?: boolean; expectedFlowRevision?: number; refuseNamed?: "pre" | "plain"; plannerPull?: { revision: number; note: string; withheld: boolean; guard: () => void } } = {}): Promise<Task> {
|
|
2637
|
+
private async assignOwner(task: Task, by: PeerId, opts: { current?: () => void; conductorManual?: true; queueOverride?: "owner"; candidates?: PeerId[]; namedOwner?: PeerId; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean; roster?: boolean; planner?: boolean; expectedFlowRevision?: number; refuseNamed?: "pre" | "plain"; plannerPull?: { revision: number; note: string; withheld: boolean; guard: () => void } } = {}): Promise<Task> {
|
|
2394
2638
|
opts.current?.();
|
|
2395
2639
|
if (task.state === "backlog" && !opts.plannerPull) throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
|
|
2396
2640
|
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
@@ -2420,7 +2664,9 @@ export class Tasks {
|
|
|
2420
2664
|
const waits = this.waitsFor(task);
|
|
2421
2665
|
const plannedMove = opts.planner || this.plannerPullRecord(task)?.pull === true && (by === HUB || opts.roster) && (opts.reason !== "manual" || opts.roster);
|
|
2422
2666
|
const plannerFull = plannedMove ? this.plannerFull(task) : [];
|
|
2423
|
-
const a = assign(opts.plannerPull ? { ...task, state: "proposed" } : task, this.states(), this.d.routing(), { exclude: [...(opts.override ? [] : this.excluded(task)), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], excludeOwners: plannerFull, ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class, task), ...(opts.namedOwner ? { namedOwner: opts.namedOwner } : {})
|
|
2667
|
+
const a = assign(opts.plannerPull ? { ...task, state: "proposed" } : task, this.states(), this.d.routing(), { exclude: [...(opts.override ? [] : this.excluded(task)), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], excludeOwners: plannerFull, ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class, task), ...(opts.namedOwner ? { namedOwner: opts.namedOwner } : {}), // A self-claim hands over no envelope; reviewer admission remains automatic.
|
|
2668
|
+
contextOverride: !!(opts.queueOverride || opts.claim),
|
|
2669
|
+
...(opts.queueOverride || opts.claim ? { queueOverride: opts.queueOverride ?? "owner" as const } : {}) });
|
|
2424
2670
|
|
|
2425
2671
|
if (waits.length) {
|
|
2426
2672
|
if (opts.plannerPull) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
|
|
@@ -2434,14 +2680,18 @@ export class Tasks {
|
|
|
2434
2680
|
const why = a.refusals?.find(row => row.role === "owner" && row.peer === opts.namedOwner)?.reason ?? "not eligible";
|
|
2435
2681
|
throw new (opts.refuseNamed === "pre" ? PreEffectToolRefusal : Error)(`cannot assign task #${task.id} to ${opts.namedOwner}: ${why}${/offline|not attached/.test(why) ? "; assign again once it is attached" : ""}`);
|
|
2436
2682
|
}
|
|
2437
|
-
if (!task.owner && task.state === "proposed") this.reportStall(task, this.ownerStallCause(task, a));
|
|
2438
2683
|
this.releaseNotice(opts.roster
|
|
2439
2684
|
? `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>`
|
|
2440
2685
|
: `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>`);
|
|
2441
2686
|
// Only a decline takes the task away from its owner; a failed console assign or escalation leaves it where it was.
|
|
2442
2687
|
if (opts.clearOnFail && task.owner) {
|
|
2443
2688
|
this.cohorts.leave(task.id);
|
|
2444
|
-
|
|
2689
|
+
task = this.d.board.update(task.id, by, "unassigned", { owner: null });
|
|
2690
|
+
}
|
|
2691
|
+
if (!task.owner && task.state === "proposed") {
|
|
2692
|
+
const cause = this.ownerStallCause(task, a, !opts.claim);
|
|
2693
|
+
// A named proposal remains bound to that target when pressure clears.
|
|
2694
|
+
this.reportStall(task, cause.kind === "no-eligible-peer" && isQueuePressureCode(cause.code) && opts.namedOwner ? { ...cause, peer: opts.namedOwner } : cause);
|
|
2445
2695
|
}
|
|
2446
2696
|
return task;
|
|
2447
2697
|
}
|
|
@@ -2452,11 +2702,12 @@ export class Tasks {
|
|
|
2452
2702
|
opts.plannerPull?.guard();
|
|
2453
2703
|
const assignmentNote = this.assignmentNote(`${opts.note ?? `to ${a.owner}`}${passed}`, a);
|
|
2454
2704
|
const binding = this.plannerBinding(task, a, !!opts.plannerPull);
|
|
2455
|
-
const assignmentExtra = { ...this.reviewMetadata(a), ...(binding ? { plannerReason: binding.plannerReason } : {}), ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) };
|
|
2705
|
+
const assignmentExtra = { ...this.reviewMetadata(a, task), ...(binding ? { plannerReason: binding.plannerReason } : {}), ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}), ...(opts.conductorManual ? { conductorManual: true as const } : {}) };
|
|
2456
2706
|
opts.current?.();
|
|
2457
2707
|
const next = opts.plannerPull
|
|
2458
2708
|
? this.d.board.pullAssigned(task.id, by, a.owner, opts.plannerPull.revision, opts.plannerPull.note, opts.plannerPull.withheld, a.reviewer ?? null, assignmentNote, assignmentExtra, task.history.length, binding)
|
|
2459
2709
|
: this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(binding ? { planner: binding.planner } : {}), ...(opts.roster && task.state === "in_progress" ? { state: "proposed" as const } : {}), ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, assignmentNote, assignmentExtra, opts.expectedFlowRevision);
|
|
2710
|
+
this.queueReviewWritten(next);
|
|
2460
2711
|
if (opts.plannerPull) this.planningRecorded({ by, task: next.id, fields: ["pull", "reserved"], revision: next.planningRevision! });
|
|
2461
2712
|
// What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
|
|
2462
2713
|
// escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
|
|
@@ -2523,11 +2774,16 @@ export class Tasks {
|
|
|
2523
2774
|
return next;
|
|
2524
2775
|
}
|
|
2525
2776
|
|
|
2526
|
-
/**
|
|
2777
|
+
/** #933: the person, or the configured conductor inside an admitted operation (`TasksDeps.operational` rechecks the role). */
|
|
2778
|
+
private personOrConductor(by: PeerId): boolean { return by === USER || this.d.operational?.(by) === true; }
|
|
2779
|
+
|
|
2780
|
+
/** Advice changes by the person or, #933, the task's own owner never change ownership, class or authority. */
|
|
2527
2781
|
setRole(by: PeerId, id: unknown, raw: unknown): Task {
|
|
2528
|
-
if (by !== USER) throw new PreEffectToolRefusal("only the person may change an existing task catalog role");
|
|
2529
2782
|
if (raw === undefined) throw new PreEffectToolRefusal("name a catalog role id or explicit null to clear it");
|
|
2530
2783
|
const task = this.need(id, false, true);
|
|
2784
|
+
if (by !== USER && task.owner !== by) throw new PreEffectToolRefusal(`task #${task.id} is not yours: an agent changes the catalog role of its own task only`);
|
|
2785
|
+
// #933: under review the role is what the reviewer reviews against; only the person changes it then.
|
|
2786
|
+
if (by !== USER && task.state === "in_review") throw new PreEffectToolRefusal(`task #${task.id} is in review: only the person changes its catalog role until the verdict`);
|
|
2531
2787
|
if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("a closed task's role is historical; propose new work instead");
|
|
2532
2788
|
let selected;
|
|
2533
2789
|
try { selected = taskRole(raw === null ? undefined : raw, task.class, this.d.catalog ?? loadRoleCatalog(this.d.cwd)); }
|
|
@@ -2537,7 +2793,7 @@ export class Tasks {
|
|
|
2537
2793
|
for (const peer of new Set([next.owner, next.state === "in_review" ? next.reviewer : null])) {
|
|
2538
2794
|
if (!peer || peer === USER) continue;
|
|
2539
2795
|
this.releaseNotice(`task ${this.publicTitle(next)}: catalog role update delivery unconfirmed`, () => this.d.bus.publish(newEnvelope(HUB,
|
|
2540
|
-
`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."}`,
|
|
2796
|
+
`Task #${next.id}: catalog role changed by ${by === USER ? "the person" : by}. ${next.role ? this.roleGuidance(next, peer, peer === next.reviewer) : "Catalog guidance cleared; follow the task's existing plan and requirements."}`,
|
|
2541
2797
|
{ to: [peer], kind: "task", refs: { task: String(next.id), taskRecipient: peer === next.reviewer ? "reviewer" : "owner" }, ...(this.isPii(next) ? { private: true } : {}) })));
|
|
2542
2798
|
}
|
|
2543
2799
|
return next;
|
|
@@ -2545,6 +2801,7 @@ export class Tasks {
|
|
|
2545
2801
|
|
|
2546
2802
|
/** A self-claim has no assignment envelope; its tool result carries the same bounded guidance. */
|
|
2547
2803
|
claimRoleGuidance(task: Task, peer: PeerId): string {
|
|
2804
|
+
requireLocalTaskPeer(peer);
|
|
2548
2805
|
return task.owner === peer && task.state === "in_progress" ? this.roleGuidance(task, peer) : "";
|
|
2549
2806
|
}
|
|
2550
2807
|
|
|
@@ -2707,7 +2964,8 @@ export class Tasks {
|
|
|
2707
2964
|
try { return this.d.offerRead?.(offer.peer, offer.envelopeId) !== true; } catch { return true; }
|
|
2708
2965
|
}).map(offer => ({ ...offer }));
|
|
2709
2966
|
}
|
|
2710
|
-
|
|
2967
|
+
/** A status-only snapshot is advisory; all effect/admission callers retain fresh health by default. */
|
|
2968
|
+
stalled(projection?: { ownerIsolation: Readonly<Record<string, boolean>> }): TaskStall[] {
|
|
2711
2969
|
const revision = this.d.board.statusRevision();
|
|
2712
2970
|
if (this.stallCandidates?.revision !== revision) this.stallCandidates = { revision, rows: this.d.board.stalls().flatMap(stall => {
|
|
2713
2971
|
const task = this.d.board.get(stall.task);
|
|
@@ -2717,37 +2975,213 @@ export class Tasks {
|
|
|
2717
2975
|
return this.waitsFor(task).length ? [] : [{ stall, task, planned: this.plannerPullRecord(task)?.pull === true }];
|
|
2718
2976
|
}) };
|
|
2719
2977
|
// Eligibility inputs are live; queue journal writes alone do not re-decode board histories.
|
|
2720
|
-
const
|
|
2978
|
+
const health = this.health(projection?.ownerIsolation);
|
|
2979
|
+
const key = JSON.stringify([revision, this.states(), this.d.routing(), health, [...this.d.bus.peers.keys()].map(peer => {
|
|
2721
2980
|
const queue = this.d.bus.queueSummary(peer);
|
|
2722
2981
|
return [peer, this.wipLimit(peer), queue.heldBy, queue.needsReview];
|
|
2723
2982
|
})]);
|
|
2724
|
-
if (this.stallView?.key !== key) this.stallView = { key, stalls: this.stallCandidates.rows.filter(({ stall, task, planned }) => stall.cause.kind === "no-writes" || 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) };
|
|
2983
|
+
if (this.stallView?.key !== key) this.stallView = { key, stalls: this.stallCandidates.rows.filter(({ stall, task, planned }) => stall.cause.kind === "no-writes" || stall.cause.kind === "cancelled-dependency" || !(planned ? this.plannerAssignment(task, true, this.d.routing(), undefined, health) : assign(task, this.states(), this.d.routing(), { exclude: this.excluded(task), ...this.weights(task.class, task, health), ...(stall.cause.kind === "no-eligible-peer" && isQueuePressureCode(stall.cause.code) && stall.cause.peer ? { candidates: [stall.cause.peer], namedOwner: stall.cause.peer } : {}) })).owner).map(({ stall }) => stall) };
|
|
2725
2984
|
return structuredClone(this.stallView.stalls);
|
|
2726
2985
|
}
|
|
2986
|
+
private queuePressureKnown: boolean | undefined;
|
|
2987
|
+
private queuePressureGeneration = 0;
|
|
2988
|
+
private queuePressureQueued = false;
|
|
2989
|
+
private queuePressureRunning = false;
|
|
2990
|
+
private queuePressureDirty = false;
|
|
2991
|
+
private queuePressureStopped = false;
|
|
2992
|
+
private queuePressurePass?: QueuePressurePass;
|
|
2993
|
+
private queuePressureContinuation?: ReturnType<typeof setImmediate>;
|
|
2994
|
+
|
|
2995
|
+
private markQueuePressureCandidate(): void {
|
|
2996
|
+
this.queuePressureKnown = true; this.queuePressureGeneration++;
|
|
2997
|
+
this.scheduleQueuePressureRecheck();
|
|
2998
|
+
}
|
|
2999
|
+
private queueReviewWritten(task: Task): void {
|
|
3000
|
+
if (task.state === "in_review" && task.reviewer === USER && task.history.at(-1)?.queueReviewHeld) this.markQueuePressureCandidate();
|
|
3001
|
+
}
|
|
3002
|
+
private queuePressureAdmitted(): boolean {
|
|
3003
|
+
try { return !this.queuePressureStopped && !this.d.sweepHeld?.() && !this.d.bus.isRecoveryHeld && !this.d.bus.storageError; }
|
|
3004
|
+
catch { return false; }
|
|
3005
|
+
}
|
|
3006
|
+
/** Run after the bus's synchronous queue checkpoint, coalescing notifications without polling. */
|
|
3007
|
+
scheduleQueuePressureRecheck(): void {
|
|
3008
|
+
if (this.queuePressureStopped || this.queuePressureKnown === false) return;
|
|
3009
|
+
this.queuePressureDirty = true;
|
|
3010
|
+
if (this.queuePressureQueued || this.queuePressureRunning || this.queuePressureContinuation) return;
|
|
3011
|
+
this.queuePressureQueued = true;
|
|
3012
|
+
queueMicrotask(() => {
|
|
3013
|
+
this.queuePressureQueued = false;
|
|
3014
|
+
if (!this.queuePressureAdmitted()) return;
|
|
3015
|
+
this.queuePressureDirty = false;
|
|
3016
|
+
const pass = this.queuePressurePass ??= { ownerAfter: 0, reviewAfter: 0, ownerDone: false, reviewDone: false, retained: false, generation: this.queuePressureGeneration };
|
|
3017
|
+
this.queuePressureRunning = true;
|
|
3018
|
+
let failed = false;
|
|
3019
|
+
void this.recheckQueuePressureBatch(pass).catch(() => {
|
|
3020
|
+
failed = true;
|
|
3021
|
+
// A failed/unknown metadata or authority read never creates an assignment.
|
|
3022
|
+
this.queuePressureKnown = true; this.queuePressurePass = undefined;
|
|
3023
|
+
}).finally(() => {
|
|
3024
|
+
this.queuePressureRunning = false;
|
|
3025
|
+
if (!this.queuePressureAdmitted()) { this.queuePressurePass = undefined; return; }
|
|
3026
|
+
if (failed) return;
|
|
3027
|
+
if (this.queuePressurePass && (!pass.ownerDone || !pass.reviewDone)) {
|
|
3028
|
+
this.queuePressureContinuation = setImmediate(() => { this.queuePressureContinuation = undefined; this.scheduleQueuePressureRecheck(); });
|
|
3029
|
+
this.queuePressureContinuation.unref?.();
|
|
3030
|
+
return;
|
|
3031
|
+
}
|
|
3032
|
+
this.queuePressurePass = undefined;
|
|
3033
|
+
const changed = pass.generation !== this.queuePressureGeneration;
|
|
3034
|
+
this.queuePressureKnown = pass.retained || changed;
|
|
3035
|
+
if ((this.queuePressureDirty || changed) && this.queuePressureKnown) this.scheduleQueuePressureRecheck();
|
|
3036
|
+
});
|
|
3037
|
+
});
|
|
3038
|
+
}
|
|
3039
|
+
/** Recovery waits for queued/running host writers; a stop fences a writer already in an await. */
|
|
3040
|
+
queuePressureRechecksPending = (): number => Number(this.queuePressureQueued || this.queuePressureRunning || this.queuePressureContinuation !== undefined);
|
|
3041
|
+
stopQueuePressureRechecks(): void {
|
|
3042
|
+
this.queuePressureStopped = true; this.queuePressureDirty = false; this.queuePressureQueued = false;
|
|
3043
|
+
if (this.queuePressureContinuation) clearImmediate(this.queuePressureContinuation);
|
|
3044
|
+
this.queuePressureContinuation = undefined; this.queuePressurePass = undefined;
|
|
3045
|
+
}
|
|
3046
|
+
private ordinaryQueueProposal(stall: TaskStall, task: Task | undefined): task is Task {
|
|
3047
|
+
return !!task && task.state === "proposed" && !task.owner && !task.draft
|
|
3048
|
+
&& !this.waitsFor(task).length && !this.personUnassigned(task) && !this.plannerPullRecord(task)?.pull;
|
|
3049
|
+
}
|
|
3050
|
+
private queueReviewBinding(task: Task): string {
|
|
3051
|
+
return createHash("sha256").update(JSON.stringify([this.reviewHead(task) ?? null, task.refs.repo ?? null, task.sdlc?.repo ?? null, task.sdlc?.pr ?? null])).digest("hex");
|
|
3052
|
+
}
|
|
3053
|
+
private queueReviewHeld(task: Task): boolean {
|
|
3054
|
+
if (task.state !== "in_review" || task.reviewer !== USER || this.isPii(task) || this.manuallyNamedReviewer(task)) return false;
|
|
3055
|
+
const role = task.history[this.reviewRequestRevision(task) - 1];
|
|
3056
|
+
return role?.queueReviewHeld === true && role.queueReviewBinding === this.queueReviewBinding(task);
|
|
3057
|
+
}
|
|
3058
|
+
private async recheckQueuePressureBatch(pass: QueuePressurePass): Promise<void> {
|
|
3059
|
+
if (!this.queuePressureAdmitted()) return;
|
|
3060
|
+
if (!pass.ownerDone) {
|
|
3061
|
+
const page = this.d.board.queuePressureStallPage(pass.ownerAfter, QUEUE_RECHECK_PAGE);
|
|
3062
|
+
pass.ownerAfter = page.afterTask; pass.ownerDone = page.done;
|
|
3063
|
+
for (const stall of page.rows) {
|
|
3064
|
+
if (!this.queuePressureAdmitted()) return;
|
|
3065
|
+
const task = this.d.board.get(stall.task);
|
|
3066
|
+
if (!this.ordinaryQueueProposal(stall, task)) { this.d.board.clearStall(stall.task); continue; }
|
|
3067
|
+
// A planning revision asks for fresh admission, never erases an automatic offer.
|
|
3068
|
+
const namedOwner = stall.cause.peer;
|
|
3069
|
+
const retry = namedOwner ? { candidates: [namedOwner], namedOwner } : {};
|
|
3070
|
+
const choice = assign(task, this.states(), this.d.routing(), { exclude: this.excluded(task), waitsFor: this.waitsFor(task), ...this.weights(task.class, task), ...retry });
|
|
3071
|
+
if (choice.owner) {
|
|
3072
|
+
const current = () => { if (!this.queuePressureAdmitted()) throw new PreEffectToolRefusal("queue-pressure retry is no longer admitted"); };
|
|
3073
|
+
try {
|
|
3074
|
+
// Reserved ready work shares the existing WIP, in-flight slot and offered fences.
|
|
3075
|
+
if (task.reserved) await this.offerReady(task, "queue pressure cleared", current);
|
|
3076
|
+
else await this.assignOwner(task, HUB, { current, ...retry });
|
|
3077
|
+
} catch (error) {
|
|
3078
|
+
if (!(error instanceof AssignmentUndeliveredError) && this.queuePressureAdmitted()) pass.retained = true;
|
|
3079
|
+
}
|
|
3080
|
+
if (!this.queuePressureAdmitted()) return;
|
|
3081
|
+
} // Pure transient eligibility reads keep the origin; only a gate that owns retries replaces it.
|
|
3082
|
+
const latest = this.d.board.get(task.id);
|
|
3083
|
+
if (this.ordinaryQueueProposal(stall, latest)) {
|
|
3084
|
+
const origin = this.d.board.queuePressureStallPage(task.id - 1, 1).rows.find(row => row.task === task.id);
|
|
3085
|
+
if (this.isHeld(latest)) this.d.board.clearStall(task.id); // WIP release owns its ready-held task now.
|
|
3086
|
+
else if (origin) {
|
|
3087
|
+
this.d.board.recordStall({ ...origin, replanRevision: latest.planningRevision ?? 0 });
|
|
3088
|
+
pass.retained = true;
|
|
3089
|
+
} else {
|
|
3090
|
+
const replacing = this.d.board.stall(task.id);
|
|
3091
|
+
const selected = choice.owner;
|
|
3092
|
+
const refusal = replacing && selected ? replacing.cause.peer === selected ? replacing.cause.code : replacing.cause.refusals?.find(row => row.peer === selected)?.code : undefined;
|
|
3093
|
+
const transient = replacing && (replacing.cause.kind === "no-eligible-peer" || replacing.cause.kind === "reservation") && replacing.cause.code !== "policy" && (refusal === "offline" || refusal === "not-attached");
|
|
3094
|
+
if (transient) {
|
|
3095
|
+
// The initially eligible owner vanished during admission; a policy gate never reaches this restore.
|
|
3096
|
+
this.d.board.recordStall({ ...stall, replanRevision: latest.planningRevision ?? 0 });
|
|
3097
|
+
pass.retained = true;
|
|
3098
|
+
}
|
|
3099
|
+
} // Other replacing gates keep their stall and own any later retry.
|
|
3100
|
+
} else this.d.board.clearStall(task.id);
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
if (!pass.reviewDone && this.queuePressureAdmitted()) {
|
|
3104
|
+
const ids = this.d.board.humanReviewPage(pass.reviewAfter, QUEUE_RECHECK_PAGE);
|
|
3105
|
+
pass.reviewAfter = ids.at(-1) ?? pass.reviewAfter; pass.reviewDone = ids.length < QUEUE_RECHECK_PAGE;
|
|
3106
|
+
for (const id of ids) {
|
|
3107
|
+
if (!this.queuePressureAdmitted()) return;
|
|
3108
|
+
const task = this.d.board.get(id);
|
|
3109
|
+
if (!task || !this.queueReviewHeld(task)) continue;
|
|
3110
|
+
const reviewer = await this.recheckQueueReview(task);
|
|
3111
|
+
if (!this.queuePressureAdmitted()) return;
|
|
3112
|
+
const latest = this.d.board.get(id);
|
|
3113
|
+
if (!reviewer && latest && this.queueReviewHeld(latest)) pass.retained = true;
|
|
3114
|
+
}
|
|
3115
|
+
}
|
|
3116
|
+
}
|
|
3117
|
+
private async recheckQueueReview(before: Task): Promise<PeerId | undefined> {
|
|
3118
|
+
const revision = this.reviewRequestRevision(before), head = this.reviewHead(before), owner = before.owner;
|
|
3119
|
+
const valid = () => {
|
|
3120
|
+
if (!this.queuePressureAdmitted()) throw new PreEffectToolRefusal("queue-pressure retry is no longer admitted");
|
|
3121
|
+
const task = this.need(before.id, false, true);
|
|
3122
|
+
if (!this.queueReviewHeld(task) || this.reviewRequestRevision(task) !== revision || this.reviewHead(task) !== head || task.owner !== owner) throw new PreEffectToolRefusal("queue-held review changed");
|
|
3123
|
+
return task;
|
|
3124
|
+
};
|
|
3125
|
+
const choose = (task: Task) => assign(task, this.states(), this.d.routing(), { ...this.weights(task.class, task), candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}) });
|
|
3126
|
+
const initial = choose(valid());
|
|
3127
|
+
if (!initial.reviewer || initial.reviewer === USER || initial.reviewBlocked) return undefined;
|
|
3128
|
+
try {
|
|
3129
|
+
const admission = await this.reviewRequestAdmission(before, undefined, true, before.owner ?? USER, undefined, () => { valid(); return true; });
|
|
3130
|
+
const task = valid(), selection = choose(task), reviewer = selection.reviewer;
|
|
3131
|
+
if (!reviewer || reviewer === USER || selection.reviewBlocked) return undefined;
|
|
3132
|
+
valid();
|
|
3133
|
+
const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, this.assignmentNote("queue pressure cleared", selection), this.reviewMetadata(selection), task.history.length);
|
|
3134
|
+
this.heldReviews.delete(next.id);
|
|
3135
|
+
// The current head is unchanged; its watcher reads the live reviewer at completion.
|
|
3136
|
+
this.releaseNotice(`task #${next.id}: queued review request unavailable`, () => this.sendReview(next, reviewer, "", admission));
|
|
3137
|
+
return reviewer;
|
|
3138
|
+
} catch { return undefined; }
|
|
3139
|
+
}
|
|
3140
|
+
|
|
2727
3141
|
/** Why a proposal's named owner (a self-claim included) did not get it, from the stall its proposal recorded (#601); undefined when it did. */
|
|
2728
3142
|
namedOwnerNote(task: Task, named: PeerId | undefined, by: PeerId): string | undefined {
|
|
3143
|
+
requireLocalTaskPeer(by);
|
|
2729
3144
|
if (!named || task.owner || task.state !== "proposed") return undefined;
|
|
2730
3145
|
const stall = this.d.board.stalls().find(row => row.task === task.id);
|
|
2731
3146
|
if (!stall) return undefined;
|
|
2732
3147
|
const code = stall.cause.refusals?.find(row => row.peer === named)?.code ?? (stall.cause.peer === named ? stall.cause.code : undefined);
|
|
2733
3148
|
return `${named} was not assigned${code ? ` (${code})` : ""}: ${stall.cause.kind}; assign it again with hub_task_assign once ${named} can take it`;
|
|
2734
3149
|
}
|
|
2735
|
-
private ownerStallCause(task: Task, admission: Assignment): TaskStall["cause"] {
|
|
3150
|
+
private ownerStallCause(task: Task, admission: Assignment, automatic = true): TaskStall["cause"] {
|
|
2736
3151
|
const refusal = admission.refusals?.find(row => row.role === "owner" && row.peer === task.reserved);
|
|
2737
3152
|
// Persist the existing stall vocabulary; the assignment/explain keeps the precise instance/family refusal.
|
|
2738
|
-
const persisted = (code: NonNullable<Assignment["refusals"]>[number]["code"]): TaskStall["cause"]["code"] => code === "instance" || code === "family" ? "policy" : code;
|
|
2739
|
-
|
|
3153
|
+
const persisted = (code: NonNullable<Assignment["refusals"]>[number]["code"]): TaskStall["cause"]["code"] => code === "context-headroom" || code === "instance" || code === "family" ? "policy" : code;
|
|
3154
|
+
const pressure = automatic ? admission.refusals?.find(row => row.role === "owner" && isQueuePressureCode(row.code)) : undefined;
|
|
3155
|
+
return refusal && !(automatic && isQueuePressureCode(refusal.code)) ? { kind: "reservation", peer: refusal.peer, code: persisted(refusal.code) } : { kind: "no-eligible-peer", code: pressure ? persisted(pressure.code) : "unavailable", refusals: admission.refusals?.filter(row => row.role === "owner").map(({ peer, code }) => ({ peer, code: persisted(code) })).sort((a, b) => a.peer.localeCompare(b.peer)) ?? [] };
|
|
2740
3156
|
}
|
|
2741
3157
|
private reportStall(task: Task, cause: TaskStall["cause"], now = Date.now(), description?: string): void {
|
|
2742
3158
|
const stall: TaskStall = { task: task.id, cause, signature: JSON.stringify(cause), replanRevision: task.planningRevision ?? 0, at: now };
|
|
2743
3159
|
if (!this.d.board.recordStall(stall)) return;
|
|
3160
|
+
if (cause.kind === "no-eligible-peer" && isQueuePressureCode(cause.code)) this.markQueuePressureCandidate();
|
|
2744
3161
|
const text = description ?? `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.`;
|
|
2745
3162
|
this.releaseNotice(text);
|
|
2746
3163
|
try { this.d.stallNotice?.(stall); } catch { /* advisory never changes task authority */ }
|
|
2747
3164
|
// Advice joins the planner's next delivery; it must not manufacture a turn beside a withheld backlog batch.
|
|
2748
3165
|
for (const peer of this.taskPlanner(task).filter(peer => this.d.bus.isRostered(peer))) this.releaseNotice(`task #${task.id}: stall notice unavailable`, () => this.d.tell?.(peer, noteLine(HUB, "finding", text)));
|
|
2749
3166
|
}
|
|
3167
|
+
/**
|
|
3168
|
+
* #901: the person's verdict on one declared claim of a document plan, with the model label they say produced it. Person only (an agent's word on its own claim is no verdict, and task
|
|
3169
|
+
* approval never supplies one). The claim is named by its 1-based slot in the task's current plan; its text is not stored, sent or echoed, so a PII task records the same counters and
|
|
3170
|
+
* its history entry stays a stub. The entry is not a transition and moves nothing; a later verdict for the same slot supersedes the earlier one (the scorecard reads the last).
|
|
3171
|
+
*/
|
|
3172
|
+
recordClaimVerdict(by: PeerId, id: unknown, input: unknown): Task {
|
|
3173
|
+
if (by !== USER) throw new Error("only the console user may record a claim verdict");
|
|
3174
|
+
if (this.d.cancellationHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) throw new Error("recovery or shutdown is holding task writes");
|
|
3175
|
+
const task = this.need(id);
|
|
3176
|
+
const claims = task.plan?.claims?.length ?? 0;
|
|
3177
|
+
if (!claims) throw new Error(`task #${task.id} declares no plan claims: add them to its plan first`);
|
|
3178
|
+
const claim = parseClaimVerdict(input, claims);
|
|
3179
|
+
// Bound to the claim list it evaluated: a hash of the plan's claims (never their text), so a later replacement or reorder of the list leaves this verdict naming no claim of the new list.
|
|
3180
|
+
return this.d.board.update(task.id, USER, "claim verdict", {}, undefined, { claim: { ...claim, list: claimListId(task.plan?.claims) } }); // no note: nothing a person typed beyond the closed fields is kept
|
|
3181
|
+
}
|
|
3182
|
+
|
|
2750
3183
|
async cancel(id: unknown, by: PeerId, input: { reason: string; supersededBy?: string; expectedFlowRevision?: number }): Promise<Task> {
|
|
3184
|
+
requireLocalTaskPeer(by);
|
|
2751
3185
|
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");
|
|
2752
3186
|
const validate = () => {
|
|
2753
3187
|
if (this.d.cancellationHeld?.() || this.d.bus.isRecoveryHeld) throw new PreEffectToolRefusal("recovery or shutdown is holding cancellation");
|
|
@@ -2795,15 +3229,21 @@ export class Tasks {
|
|
|
2795
3229
|
}
|
|
2796
3230
|
|
|
2797
3231
|
async releaseReady(): Promise<void> {
|
|
3232
|
+
const haveQueueOrigins = this.queuePressureKnown !== false && this.d.board.queuePressureStallPage(0, 1).afterTask > 0;
|
|
2798
3233
|
for (const t of [...this.d.board.list("proposed")].sort(this.readyOrder)) {
|
|
2799
3234
|
if (t.owner) { this.d.board.clearStall(t.id); continue; }
|
|
2800
3235
|
const cancelledDependency = t.deps?.find(id => this.d.board.get(id)?.state === "cancelled");
|
|
2801
3236
|
if (cancelledDependency) { this.reportStall(t, { kind: "cancelled-dependency", dependency: cancelledDependency, code: "cancelled" }); continue; }
|
|
2802
3237
|
const last = t.history.findLast(entry => !QUIET_EVENTS.has(entry.event))?.event, planned = this.plannerPullRecord(t)?.pull === true;
|
|
2803
3238
|
if (this.personUnassigned(t) || this.offered.has(t.id) || this.waitsFor(t).length) continue;
|
|
2804
|
-
|
|
3239
|
+
if (haveQueueOrigins) {
|
|
3240
|
+
const origin = this.d.board.queuePressureStallPage(t.id - 1, 1).rows.find(row => row.task === t.id);
|
|
3241
|
+
if (origin && this.ordinaryQueueProposal(origin, t)) { this.scheduleQueuePressureRecheck(); continue; }
|
|
3242
|
+
}
|
|
3243
|
+
const admission = planned ? this.plannerAssignment(t, true) : assign(t, this.states(), this.d.routing(), { exclude: this.excluded(t), ...this.weights(t.class, t) });
|
|
2805
3244
|
if (!admission.owner) {
|
|
2806
|
-
|
|
3245
|
+
// A diagnostic legacy pass is not evidence that an automatic proposal was attempted.
|
|
3246
|
+
this.reportStall(t, this.ownerStallCause(t, admission, this.hasPersonReservation(t)));
|
|
2807
3247
|
continue;
|
|
2808
3248
|
}
|
|
2809
3249
|
this.d.board.clearStall(t.id);
|
|
@@ -2815,7 +3255,8 @@ export class Tasks {
|
|
|
2815
3255
|
}
|
|
2816
3256
|
|
|
2817
3257
|
/** Callers hold a list read before an await: re-read, or a task the other caller offered meanwhile is offered twice. */
|
|
2818
|
-
private async offerReady(stale: Task, why: string): Promise<void> {
|
|
3258
|
+
private async offerReady(stale: Task, why: string, current?: () => void): Promise<void> {
|
|
3259
|
+
current?.();
|
|
2819
3260
|
let offering = false, undelivered: AssignmentUndeliveredError | undefined, slot: PeerId | undefined;
|
|
2820
3261
|
try {
|
|
2821
3262
|
const t = this.d.board.get(stale.id);
|
|
@@ -2832,7 +3273,7 @@ export class Tasks {
|
|
|
2832
3273
|
offering = true;
|
|
2833
3274
|
const ready = t.history.at(-1)?.event === "ready" ? t : this.d.board.update(t.id, HUB, "ready", {}, why);
|
|
2834
3275
|
this.releaseNotice(`task ${this.publicTitle(ready)} is ready: what it waited for is approved`);
|
|
2835
|
-
await this.assignOwner(ready, HUB, planned ? { candidates: [selected!], namedOwner: selected!, planner: true } : {});
|
|
3276
|
+
await this.assignOwner(ready, HUB, { current, ...(planned ? { candidates: [selected!], namedOwner: selected!, planner: true } : {}) });
|
|
2836
3277
|
} catch (e) {
|
|
2837
3278
|
// A saved move whose delivery failed is not "could not be assigned" (#297); the console notice was printed
|
|
2838
3279
|
// where the error was thrown. The offer stays consumed by the error's own evidence, without the readback.
|
|
@@ -2852,6 +3293,7 @@ export class Tasks {
|
|
|
2852
3293
|
}
|
|
2853
3294
|
|
|
2854
3295
|
private mine(task: Task, by: PeerId, role: "owner" | "reviewer", preEffect = false): void {
|
|
3296
|
+
requireLocalTaskPeer(by);
|
|
2855
3297
|
if (by !== USER && task[role] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: only its ${role} (${task[role] ?? "none"}) or the console user can do that`);
|
|
2856
3298
|
}
|
|
2857
3299
|
|
|
@@ -2874,6 +3316,31 @@ export class Tasks {
|
|
|
2874
3316
|
return names.length ? `task #${task.id}: the title names ${names.join(", ")}; set refs.repo to that repository (owner/name) so its issue links and the lifecycle shows it` : undefined;
|
|
2875
3317
|
}
|
|
2876
3318
|
|
|
3319
|
+
/**
|
|
3320
|
+
* #798 D1: a declared path (refs, plan paths, the file part of a section anchor) inside a git submodule of this project: snapshots, facts, overlap and undo do not observe
|
|
3321
|
+
* inside a submodule. A notice, never a refusal: the task stays valid. Advice only, so a PII task gets none, a path whose name matches a PII pattern is not named, and a
|
|
3322
|
+
* task bound to another repository is judged against that one's layout, which this project's `.gitmodules` does not describe (its scope is not the project's).
|
|
3323
|
+
*/
|
|
3324
|
+
submoduleNotice(task: Task): string | undefined {
|
|
3325
|
+
if (this.isPii(task)) return undefined;
|
|
3326
|
+
let submodules: string[];
|
|
3327
|
+
try { submodules = this.d.submodules?.() ?? []; } catch { return undefined; }
|
|
3328
|
+
if (!submodules.length) return undefined;
|
|
3329
|
+
if (this.repositories.scope(task.refs) !== this.repositories.project) return undefined;
|
|
3330
|
+
const declared = [...(task.refs.paths ?? []), ...(task.plan?.paths ?? []), ...(task.plan?.sections ?? []).map(anchor => anchor.split("#")[0]!).filter(Boolean)];
|
|
3331
|
+
const inside = new Set<string>();
|
|
3332
|
+
for (const path of declared) {
|
|
3333
|
+
let local: string;
|
|
3334
|
+
try { local = this.projectPath(path); } catch { continue; }
|
|
3335
|
+
if (local === ".." || local.startsWith("../") || isAbsolute(local)) continue;
|
|
3336
|
+
const hit = submodules.find(sub => local === sub || local.startsWith(`${sub}/`));
|
|
3337
|
+
if (hit) inside.add(hit);
|
|
3338
|
+
}
|
|
3339
|
+
const named = [...inside].filter(this.nameable).sort();
|
|
3340
|
+
if (!named.length) return inside.size ? "inside a submodule (path withheld, it matches a PII pattern): facts, overlap and undo do not observe it" : undefined;
|
|
3341
|
+
return named.map(path => `inside submodule ${path}: facts, overlap and undo do not observe it`).join("\n");
|
|
3342
|
+
}
|
|
3343
|
+
|
|
2877
3344
|
/** #694: write the one issue the task itself names (the plan-review source) into its lifecycle receipt, once; never relinks. */
|
|
2878
3345
|
private linkIssue(task: Task): Task {
|
|
2879
3346
|
if (this.isPii(task) || task.sdlc?.issue) return task;
|
|
@@ -3028,7 +3495,53 @@ export class Tasks {
|
|
|
3028
3495
|
const byKind: Record<string, number> = {};
|
|
3029
3496
|
for (const stall of this.stalled()) if (visibleIds.has(stall.task)) byKind[stall.cause.kind] = (byKind[stall.cause.kind] ?? 0) + 1;
|
|
3030
3497
|
const stalls = { open: Object.values(byKind).reduce((sum, n) => sum + n, 0), byKind, doneWithoutWrites: rows.filter(task => visibleIds.has(task.id) && task.history.findLast(entry => entry.event === "done")?.noWrites).length };
|
|
3031
|
-
|
|
3498
|
+
// #481 S7: the merge-time readback the guarded merge recorded on each merged task's receipt (host-written, never a tool input), and the two series that are not made of tasks.
|
|
3499
|
+
const mergedAt: Record<number, number> = {}, ciReruns: Record<number, number | null> = {}, ciRuns: Record<number, number | null> = {}, reviews: Record<number, number | null> = {};
|
|
3500
|
+
for (const task of rows) {
|
|
3501
|
+
const merge = task.sdlc?.merge;
|
|
3502
|
+
if (!visibleIds.has(task.id) || merge?.state !== "merged") continue;
|
|
3503
|
+
const cost = cleanMergeCost(merge.cost);
|
|
3504
|
+
const at = cost?.mergedAt ?? (Number.isSafeInteger(merge.at) ? merge.at : null);
|
|
3505
|
+
if (at !== null) mergedAt[task.id] = at;
|
|
3506
|
+
if (cost) { ciReruns[task.id] = cost.ciReruns; ciRuns[task.id] = cost.ciRuns; reviews[task.id] = cost.reviews; }
|
|
3507
|
+
}
|
|
3508
|
+
return sdlcMetrics(tasks, { ...(cutoff !== undefined ? { since: cutoff } : {}), ...(this.d.sdlc?.config().bands ? { bands: this.d.sdlc.config().bands } : {}), stalls, mergedAt, ciReruns, ciRuns, reviews, external: this.externalSeries(), ...(merged ?? {}) });
|
|
3509
|
+
}
|
|
3510
|
+
|
|
3511
|
+
/** #481 S7: one point per shipped release (1 for a hotfix, 0 otherwise; a release with no recorded train is skipped, never a 0) and one per recorded security scan whose total is known. */
|
|
3512
|
+
private externalSeries(): Partial<Record<BandMetric, SeriesPoint[]>> {
|
|
3513
|
+
const rows = this.d.board.list(), privateRelease = (id: number) => rows.some(task => task.release === id && this.isPii(task));
|
|
3514
|
+
const hotfix = this.d.board.releases().filter(release => release.state === "shipped" && release.shippedAt !== null && release.train?.history.length && !privateRelease(release.id))
|
|
3515
|
+
.sort((a, b) => a.shippedAt! - b.shippedAt! || a.id - b.id).map(release => ({ value: release.train!.hotfix ? 1 : 0, task: release.id }));
|
|
3516
|
+
const config = this.d.sdlc?.config().securityScan, repo = config?.repo ?? this.d.repository;
|
|
3517
|
+
// A band needs `window` baseline points plus the newest one, and the episode walk looks back up to EPISODE_LOOKBACK more, so the series loads that many known
|
|
3518
|
+
// totals (unknown ones are filtered in SQL, never counted against the limit). The board caps one read at 1000 rows.
|
|
3519
|
+
const need = (this.d.sdlc?.config().bands?.security_open_alerts?.window ?? DEFAULT_BAND.window) + EPISODE_LOOKBACK + 2;
|
|
3520
|
+
const scans = config && typeof repo === "string" ? this.d.board.securityScans(repo, config.sources.join(","), need, true).map(scan => ({ value: scan.total!, task: scan.id })) : [];
|
|
3521
|
+
return { hotfix_rate: hotfix, security_open_alerts: scans };
|
|
3522
|
+
}
|
|
3523
|
+
|
|
3524
|
+
/**
|
|
3525
|
+
* #481 S7: the scheduled security-scan baseline. Reads the open-alert counts GitHub already holds (`security-baseline.ts`), records one row per interval and then runs the
|
|
3526
|
+
* same band path as every other metric, so a breach is one deduplicated card. Held like every host writer; a private repository identity, a mode off, no `[security_scan]`
|
|
3527
|
+
* table and a scan that is not yet due all do nothing. A source GitHub will not read (not enabled, not permitted) records an unknown total, never zero. Returns the scan id.
|
|
3528
|
+
*/
|
|
3529
|
+
async runSecurityScan(now = Date.now()): Promise<number | undefined> {
|
|
3530
|
+
const config = this.d.sdlc?.config(), scan = config?.securityScan;
|
|
3531
|
+
if (!config || config.mode === "off" || !scan) return undefined;
|
|
3532
|
+
const repo = scan.repo ?? this.d.repository;
|
|
3533
|
+
if (typeof repo !== "string" || !this.nameable(repo)) return undefined;
|
|
3534
|
+
const held = () => !!this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || !!this.d.bus.storageError;
|
|
3535
|
+
if (held()) return undefined;
|
|
3536
|
+
const sources = scan.sources.join(","), last = this.d.board.securityScans(repo, sources, 1).at(-1);
|
|
3537
|
+
if (last && now - last.at < scan.everyH * 3_600_000) return undefined;
|
|
3538
|
+
const read = await readSecurityScan(repo, scan.sources, this.d.sdlc?.gh ?? sdlcGh);
|
|
3539
|
+
const after = this.d.sdlc?.config();
|
|
3540
|
+
// The read awaited: the lifecycle, the schedule or a hold may have changed meanwhile, so nothing is written from a stale decision.
|
|
3541
|
+
if (!read || held() || !after || after.mode === "off" || JSON.stringify(after.securityScan) !== JSON.stringify(scan)) return undefined;
|
|
3542
|
+
const id = this.d.board.recordSecurityScan({ repo, sources, counts: read.counts as Record<string, number | null>, total: read.total });
|
|
3543
|
+
this.proposeFromBands();
|
|
3544
|
+
return id;
|
|
3032
3545
|
}
|
|
3033
3546
|
|
|
3034
3547
|
/**
|
|
@@ -3047,10 +3560,14 @@ export class Tasks {
|
|
|
3047
3560
|
bandsState(): { ready: boolean; signature: string } {
|
|
3048
3561
|
const config = this.d.sdlc?.config();
|
|
3049
3562
|
const configured = !!config && config.mode !== "off" && !!config.bands && Object.keys(config.bands).length > 0;
|
|
3050
|
-
|
|
3563
|
+
// The release and scan series do not move the task board's revision, so what they last held is part of the signature: a shipped release or a new scan row is a reason to run again.
|
|
3564
|
+
const external = configured ? this.externalSeries() : {};
|
|
3565
|
+
return { ready: configured && !this.d.sweepHeld?.() && !this.d.bus.isRecoveryHeld && !this.d.bus.storageError,
|
|
3566
|
+
signature: configured ? JSON.stringify([config!.mode, config!.bands, config!.securityScan ?? null, external.hotfix_rate?.length ?? 0, external.security_open_alerts?.at(-1)?.task ?? 0]) : "" };
|
|
3051
3567
|
}
|
|
3052
3568
|
|
|
3053
3569
|
proposeFromBands(by: PeerId = HUB): number[] {
|
|
3570
|
+
requireLocalTaskPeer(by);
|
|
3054
3571
|
const config = this.d.sdlc?.config();
|
|
3055
3572
|
if (!config || config.mode === "off" || !config.bands || !Object.keys(config.bands).length) return [];
|
|
3056
3573
|
if (this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) return [];
|
|
@@ -3064,22 +3581,24 @@ export class Tasks {
|
|
|
3064
3581
|
// A card counts as open while its task is not closed: the planner has not yet decided on it.
|
|
3065
3582
|
const open = new Set(bandRows.filter(row => !isClosedTaskState(row.state)).map(row => keyOf(row).split(":")[0]!));
|
|
3066
3583
|
const created: number[] = [];
|
|
3584
|
+
// Fixed words and a number: a release or a security scan is named by its id, a task by `task #id`.
|
|
3585
|
+
const subjectText = (band: BandResult, id: number) => band.subject === "release" ? `release #${id}` : band.subject === "scan" ? `security scan #${id}` : `task #${id}`;
|
|
3067
3586
|
for (const band of report.bands as BandResult[]) {
|
|
3068
3587
|
if (band.tier === "ok" || band.lastTask === null || band.episode === null) continue;
|
|
3069
3588
|
const key = `${band.metric}:#${band.episode}`;
|
|
3070
3589
|
if (band.tier === "log") {
|
|
3071
3590
|
const marker = `${key}:log`;
|
|
3072
|
-
if (!open.has(band.metric) && this.d.board.recordBandNotice(marker)) this.releaseNotice(`sdlc band: ${band.metric} is ${band.reason}; last value ${band.last} on
|
|
3591
|
+
if (!open.has(band.metric) && this.d.board.recordBandNotice(marker)) this.releaseNotice(`sdlc band: ${band.metric} is ${band.reason}; last value ${band.last} on ${subjectText(band, band.lastTask)}`);
|
|
3073
3592
|
continue;
|
|
3074
3593
|
}
|
|
3075
3594
|
if (seen.has(`${key}:propose`) || seen.has(`${key}:diagnose`) || open.has(band.metric)) continue;
|
|
3076
3595
|
const tier = band.tier === "propose" ? "propose" : "diagnose";
|
|
3077
3596
|
const full = `${key}:${tier}`;
|
|
3078
|
-
const evidence = `metric ${band.metric}; newest value ${band.last} on
|
|
3597
|
+
const evidence = `metric ${band.metric}; newest value ${band.last} on ${subjectText(band, band.lastTask)}; episode began at ${subjectText(band, band.episode)}; baseline mean ${band.mean}, deviation ${band.sd} over ${band.baseline} ${band.subject === "release" ? "releases" : band.subject === "scan" ? "scans" : "tasks"}; ${band.reason}`;
|
|
3079
3598
|
const diagnose = tier === "diagnose";
|
|
3080
3599
|
const title = diagnose ? `Diagnose SDLC metric ${band.metric}: read-only triage` : `Review SDLC metric ${band.metric}: control band breach`;
|
|
3081
3600
|
const detail = `Band breach key: ${full}\n${evidence}\n${diagnose
|
|
3082
|
-
?
|
|
3601
|
+
? `Read-only triage for the planner: inspect the recent ${band.subject === "release" ? "releases" : band.subject === "scan" ? "security scans" : "task histories"} behind this metric and report what changed. Do not edit files, branches or the board.`
|
|
3083
3602
|
: "Backlog card for the planner: decide whether this breach needs a process change. It names the metric and its evidence; it opens no GitHub issue."}`;
|
|
3084
3603
|
const draft = { title, detail, refs: {} };
|
|
3085
3604
|
const signals = this.signalsFor(draft);
|
|
@@ -3091,6 +3610,167 @@ export class Tasks {
|
|
|
3091
3610
|
return created;
|
|
3092
3611
|
}
|
|
3093
3612
|
|
|
3613
|
+
/** The open task each schedule made: a hub-written card (first history entry by the hub) whose detail starts with the schedule marker and that is not closed. */
|
|
3614
|
+
openScheduled(): Map<string, number> {
|
|
3615
|
+
const open = new Map<string, number>();
|
|
3616
|
+
for (const row of this.d.board.list()) {
|
|
3617
|
+
const key = row.history[0]?.by === HUB && !isClosedTaskState(row.state) ? scheduleKeyOf(row.detail) : undefined;
|
|
3618
|
+
if (key !== undefined && !open.has(key)) open.set(key, row.id);
|
|
3619
|
+
}
|
|
3620
|
+
return open;
|
|
3621
|
+
}
|
|
3622
|
+
|
|
3623
|
+
/** Whether a scheduled run may write now: the same holds as every host writer (stopping or recovering, held by the daemon, a storage error). */
|
|
3624
|
+
schedulesHeld(): boolean { return !!this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || !!this.d.bus.storageError; }
|
|
3625
|
+
|
|
3626
|
+
private schedulesRunning = false;
|
|
3627
|
+
/** 1 while a schedules pass is running (its PII screen and triage are awaited), so a recovery commit waits for it like it waits for `checksPending` and `autoPullsPending`: a run mid-admission would write after the commit's digest. */
|
|
3628
|
+
schedulesPending = (): number => (this.schedulesRunning ? 1 : 0);
|
|
3629
|
+
/**
|
|
3630
|
+
* One due schedule's run: the hold, the open-task rule and the proposal, shared by the timer and `ahub schedule run`, so a manual run can never do what a due
|
|
3631
|
+
* one may not. `propose` asks the fence again right before its board write, so a run admitted and then overtaken by a hold writes nothing and counts as a skip.
|
|
3632
|
+
*/
|
|
3633
|
+
private async runSchedule(store: ScheduleStore, entry: ScheduleEntry, now: number, extraHold: () => boolean, manual = false): Promise<ScheduleRunResult> {
|
|
3634
|
+
const hold = () => this.schedulesHeld() || extraHold() ? "the hub is held" : undefined;
|
|
3635
|
+
// A skipped tick is counted for the timer only: a person's refused run is not a missed period, so it never feeds the coalescing count.
|
|
3636
|
+
const skip = () => { if (!manual) store.skipped(entry.id); };
|
|
3637
|
+
if (hold()) { skip(); return { id: entry.id, outcome: "held" }; }
|
|
3638
|
+
const open = this.openScheduled().get(entry.id);
|
|
3639
|
+
if (open !== undefined) { skip(); return { id: entry.id, outcome: "waiting", task: open }; }
|
|
3640
|
+
// The PII screen and triage are awaited inside `propose`, and a hold can begin during them (shutdown, recovery, a storage error). `propose` asks this fence
|
|
3641
|
+
// again right before its board write, so a run that was admitted and then overtaken by a hold writes nothing and counts as a skip, like one held at the start.
|
|
3642
|
+
// The same fence also reads the schedule's pause generation (#335): a person who paused or resumed it after this run was admitted has changed what should happen,
|
|
3643
|
+
// so the run writes nothing. A timer run that finds it paused or resumed meanwhile is dropped (the next pass decides); a person's own run never races its own command.
|
|
3644
|
+
const admitted = store.epoch(entry.id);
|
|
3645
|
+
let overtaken = false, changed = false;
|
|
3646
|
+
const fence = () => {
|
|
3647
|
+
const reason = hold(); if (reason) overtaken = true;
|
|
3648
|
+
if (!reason && !manual && store.epoch(entry.id) !== admitted) { changed = true; return "the schedule was paused or resumed"; }
|
|
3649
|
+
return reason;
|
|
3650
|
+
};
|
|
3651
|
+
try {
|
|
3652
|
+
const task = await this.propose(HUB, { title: entry.title, detail: scheduledDetail(entry), class: entry.class, backlog: entry.queue === "backlog",
|
|
3653
|
+
...(entry.owner ? { owner: entry.owner } : {}) }, undefined, undefined, fence);
|
|
3654
|
+
store.ran(entry.id, now, task.id);
|
|
3655
|
+
return { id: entry.id, outcome: "ran", task: task.id };
|
|
3656
|
+
} catch (error) {
|
|
3657
|
+
if (changed) return { id: entry.id, outcome: "changed" };
|
|
3658
|
+
if (overtaken) { try { skip(); } catch { /* the store closed with the hub */ } return { id: entry.id, outcome: "held" }; }
|
|
3659
|
+
store.failed(entry.id, sanitize((error as Error).message));
|
|
3660
|
+
return { id: entry.id, outcome: "failed" };
|
|
3661
|
+
}
|
|
3662
|
+
}
|
|
3663
|
+
|
|
3664
|
+
/**
|
|
3665
|
+
* #512a: one pass over the machine-local schedules. A schedule is due once `every` has passed since its period began (first sight, then its last run).
|
|
3666
|
+
* A due schedule proposes at most one task per pass, through `propose` like any proposal (the PII screen, triage, routing and notices all apply), and none
|
|
3667
|
+
* while the task its previous run made is still open. While the hub is held the whole pass skips and every due schedule counts the skip, so a long hold
|
|
3668
|
+
* ends in one run per schedule, never a burst. A run restarts the period at `now`: nothing a restart or a hold missed is replayed. Returns what each due schedule did.
|
|
3669
|
+
*/
|
|
3670
|
+
async proposeFromSchedules(store: ScheduleStore, entries: readonly ScheduleEntry[], now: number, extraHold: () => boolean = () => false): Promise<ScheduleRunResult[]> {
|
|
3671
|
+
if (this.schedulesRunning) return [];
|
|
3672
|
+
this.schedulesRunning = true;
|
|
3673
|
+
const out: ScheduleRunResult[] = [];
|
|
3674
|
+
try {
|
|
3675
|
+
store.prune(entries.map(entry => entry.id));
|
|
3676
|
+
for (const entry of entries) {
|
|
3677
|
+
const row = store.ensure(entry.id, now);
|
|
3678
|
+
if (row.paused) continue; // #335: a person's pause is neither a run nor a skipped tick
|
|
3679
|
+
if (now < row.anchor + parseEvery(entry.every)) continue;
|
|
3680
|
+
out.push(await this.runSchedule(store, entry, now, extraHold));
|
|
3681
|
+
}
|
|
3682
|
+
} finally { this.schedulesRunning = false; }
|
|
3683
|
+
return out;
|
|
3684
|
+
}
|
|
3685
|
+
|
|
3686
|
+
/**
|
|
3687
|
+
* #335: `ahub schedule run <id>`. The person runs one configured schedule now, whatever its period says. It takes the same pass guard as the timer (so it never
|
|
3688
|
+
* overlaps one), the same hold, open-task and `propose` path (`runSchedule`), and restarts that schedule's period on success like a due run. A paused schedule
|
|
3689
|
+
* runs too: a pause stops the timer, not the person. Only the person may.
|
|
3690
|
+
*/
|
|
3691
|
+
async runScheduleNow(by: PeerId, store: ScheduleStore, entries: readonly ScheduleEntry[], id: unknown, now: number, extraHold: () => boolean = () => false): Promise<ScheduleRunResult & { reason?: string }> {
|
|
3692
|
+
if (!this.personOrConductor(by)) throw new PreEffectToolRefusal("schedule run is a console command");
|
|
3693
|
+
const entry = typeof id === "string" ? entries.find(candidate => candidate.id === id) : undefined;
|
|
3694
|
+
if (!entry) throw new PreEffectToolRefusal(`no schedule ${typeof id === "string" ? JSON.stringify(id.slice(0, 64)) : "named"}; ahub schedule list shows the configured ones`);
|
|
3695
|
+
if (this.schedulesRunning) throw new PreEffectToolRefusal("a schedules pass is running; try again in a moment");
|
|
3696
|
+
this.schedulesRunning = true;
|
|
3697
|
+
try {
|
|
3698
|
+
store.ensure(entry.id, now);
|
|
3699
|
+
// #933: the board-write fence asks the hold again after the PII screen, so a conductor whose role or connection ended meanwhile writes nothing.
|
|
3700
|
+
const result = await this.runSchedule(store, entry, now, () => extraHold() || !this.personOrConductor(by), true);
|
|
3701
|
+
const reason = result.outcome === "failed" ? store.get(entry.id)?.last_error ?? undefined : undefined;
|
|
3702
|
+
return reason ? { ...result, reason } : result;
|
|
3703
|
+
} finally { this.schedulesRunning = false; }
|
|
3704
|
+
}
|
|
3705
|
+
|
|
3706
|
+
/** #335: pause or resume one configured schedule. Only the person may; resuming starts a fresh period, so a pause is never replayed as a run. */
|
|
3707
|
+
setSchedulePaused(by: PeerId, store: ScheduleStore, entries: readonly ScheduleEntry[], id: unknown, paused: boolean, now: number): void {
|
|
3708
|
+
if (!this.personOrConductor(by)) throw new PreEffectToolRefusal(`schedule ${paused ? "pause" : "resume"} is a console command`);
|
|
3709
|
+
const entry = typeof id === "string" ? entries.find(candidate => candidate.id === id) : undefined;
|
|
3710
|
+
if (!entry) throw new PreEffectToolRefusal(`no schedule ${typeof id === "string" ? JSON.stringify(id.slice(0, 64)) : "named"}; ahub schedule list shows the configured ones`);
|
|
3711
|
+
store.setPaused(entry.id, paused, now);
|
|
3712
|
+
}
|
|
3713
|
+
|
|
3714
|
+
/**
|
|
3715
|
+
* #336 (#512c): one pass over the machine-local folder triggers, on the schedules timer. Each trigger's folder is read once (never watched). The first poll of a trigger
|
|
3716
|
+
* records what is already there and proposes nothing, so adding a trigger to a full inbox does not flood the board; after that a file the trigger has not recorded (by
|
|
3717
|
+
* real path and content hash) is registered as a source through `register` and proposes ONE task through `propose` as the hub, which names the source id and never the
|
|
3718
|
+
* file's name or path. A rename or a touch keeps the hash and proposes nothing. While the hub is held nothing is read or recorded, so the files wait for the next poll.
|
|
3719
|
+
* A file is recorded only after its task exists, so a failure or a hold between the two leaves it to be tried again (a crash in that window can repeat one task; it
|
|
3720
|
+
* never loses a file). At most MAX_NEW_PER_POLL files per trigger per pass.
|
|
3721
|
+
*/
|
|
3722
|
+
async runFolderTriggers(store: FolderStorePort, entries: readonly FolderEntry[], scan: (entry: FolderEntry) => FolderScan, register: (file: ScannedFile) => { id: string; private: boolean }, now: number, extraHold: () => boolean = () => false): Promise<FolderRunResult[]> {
|
|
3723
|
+
if (this.schedulesRunning) return [];
|
|
3724
|
+
this.schedulesRunning = true;
|
|
3725
|
+
const out: FolderRunResult[] = [];
|
|
3726
|
+
try {
|
|
3727
|
+
store.prune(entries.map(entry => entry.id));
|
|
3728
|
+
for (const entry of entries) out.push(await this.runFolderTrigger(store, entry, scan, register, now, extraHold));
|
|
3729
|
+
} finally { this.schedulesRunning = false; }
|
|
3730
|
+
return out;
|
|
3731
|
+
}
|
|
3732
|
+
|
|
3733
|
+
private async runFolderTrigger(store: FolderStorePort, entry: FolderEntry, scanOf: (entry: FolderEntry) => FolderScan, register: (file: ScannedFile) => { id: string; private: boolean }, now: number, extraHold: () => boolean): Promise<FolderRunResult> {
|
|
3734
|
+
const hold = () => this.schedulesHeld() || extraHold() ? "the hub is held" : undefined;
|
|
3735
|
+
if (hold()) return { id: entry.id, outcome: "held", tasks: [], files: 0 };
|
|
3736
|
+
const scan = scanOf(entry);
|
|
3737
|
+
// A refused folder never starts the trigger: when it becomes readable, what is in it then is the baseline, not news.
|
|
3738
|
+
if (scan.refused) { store.polled(entry.id, now, scan.refused); return { id: entry.id, outcome: "refused", tasks: [], files: 0, reason: scan.refused }; }
|
|
3739
|
+
// The baseline is every file already in the folder when the trigger starts. A folder too big to hash in one poll (the scan's budget, `truncated`) is baselined over
|
|
3740
|
+
// several polls: each records what it read and proposes nothing, and the poll that reaches the end of the folder completes it. Only then is a file news.
|
|
3741
|
+
if (!store.baselined(entry.id)) {
|
|
3742
|
+
store.begin(entry.id, now);
|
|
3743
|
+
for (const file of scan.files) if (!file.seen) store.record(entry.id, file, null, now);
|
|
3744
|
+
if (!scan.truncated) store.markBaselined(entry.id, now); else store.polled(entry.id, now);
|
|
3745
|
+
return { id: entry.id, outcome: "baseline", tasks: [], files: scan.files.length };
|
|
3746
|
+
}
|
|
3747
|
+
// A file the trigger already knows by content (a rename, a touch, a copy) proposes nothing, but its new path, size and mtime are recorded, so the next scan finds it with
|
|
3748
|
+
// `known` instead of hashing it again: otherwise such files would spend the hash budget on every poll and starve a genuinely new one behind them.
|
|
3749
|
+
for (const file of scan.files) if (!file.seen && !store.isNew(entry.id, file.sha256)) store.observe(entry.id, file, now);
|
|
3750
|
+
// `isNew` is asked again for each file below, not only here: two files with the same new bytes can arrive before one poll, and the first one's record must suppress the second.
|
|
3751
|
+
const fresh = scan.files.filter(file => !file.seen && store.isNew(entry.id, file.sha256));
|
|
3752
|
+
const tasks: number[] = [];
|
|
3753
|
+
let error: string | undefined;
|
|
3754
|
+
for (const file of fresh) {
|
|
3755
|
+
if (tasks.length >= MAX_NEW_PER_POLL || hold()) break;
|
|
3756
|
+
if (!store.isNew(entry.id, file.sha256)) { store.observe(entry.id, file, now); continue; } // a duplicate found inside the loop: recorded, never a second task
|
|
3757
|
+
let overtaken = false;
|
|
3758
|
+
const fence = () => { const reason = hold(); if (reason) overtaken = true; return reason; };
|
|
3759
|
+
try {
|
|
3760
|
+
const source = register(file);
|
|
3761
|
+
const task = await this.propose(HUB, { title: entry.title, detail: folderDetail(entry, source.id), class: entry.class, backlog: entry.queue === "backlog", ...(entry.owner ? { owner: entry.owner } : {}) }, undefined, undefined, fence);
|
|
3762
|
+
store.record(entry.id, file, task.id, now);
|
|
3763
|
+
tasks.push(task.id);
|
|
3764
|
+
} catch (caught) {
|
|
3765
|
+
if (overtaken) break;
|
|
3766
|
+
error = sanitize((caught as Error).message);
|
|
3767
|
+
break; // the next poll tries this file again; later files wait behind it so order is kept
|
|
3768
|
+
}
|
|
3769
|
+
}
|
|
3770
|
+
store.polled(entry.id, now, error);
|
|
3771
|
+
return { id: entry.id, outcome: error ? "failed" : tasks.length ? "ran" : "none", tasks, files: fresh.length, ...(scan.truncated ? { more: true as const } : {}), ...(error ? { reason: error } : {}) };
|
|
3772
|
+
}
|
|
3773
|
+
|
|
3094
3774
|
/** S10 read-only planner report; S7 owns rolling bands and automatic board proposals. */
|
|
3095
3775
|
async costReport(by: PeerId, repository?: unknown, scopePrs?: readonly number[]): Promise<Record<string, unknown>> {
|
|
3096
3776
|
const authority = () => {
|
|
@@ -3130,7 +3810,7 @@ export class Tasks {
|
|
|
3130
3810
|
const scoped = current.filter(task => visiblePrs.has(task.sdlc!.pr!) && !this.isPii(task));
|
|
3131
3811
|
const scopedIds = new Set(scoped.map(task => task.id));
|
|
3132
3812
|
const stalls = { noWrites: this.stalled().filter(stall => stall.cause.kind === "no-writes" && scopedIds.has(stall.task)).length, doneWithoutWrites: scoped.filter(task => task.history.findLast(entry => entry.event === "done")?.noWrites).length };
|
|
3133
|
-
return { ...report, stalls, truncated: report.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}), bands: "
|
|
3813
|
+
return { ...report, stalls, truncated: report.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}), bands: "see ahub sdlc metrics" };
|
|
3134
3814
|
}
|
|
3135
3815
|
|
|
3136
3816
|
async planReviewGap(task: Task): Promise<string | undefined> {
|
|
@@ -3205,18 +3885,21 @@ export class Tasks {
|
|
|
3205
3885
|
* and only then is the rest recorded as before. A receipt with deliverables and nothing else records just the deliverables.
|
|
3206
3886
|
*/
|
|
3207
3887
|
async recordSdlc(by: PeerId, id: unknown, input: unknown): Promise<Task> {
|
|
3888
|
+
const requestCheck = this.requestCheck(by);
|
|
3208
3889
|
if (!input || typeof input !== "object" || Array.isArray(input) || !("deliverables" in input)) return this.recordLifecycle(by, id, input);
|
|
3209
3890
|
const { deliverables, ...receipt } = input as Record<string, unknown>;
|
|
3210
3891
|
const task = this.need(id, false, true);
|
|
3211
3892
|
if (by !== USER && by !== task.owner && by !== task.reviewer) throw new PreEffectToolRefusal("only the task owner, reviewer or console user may record lifecycle evidence");
|
|
3212
3893
|
const claims = this.deliverableClaims(deliverables, task);
|
|
3213
3894
|
const records = await this.hashClaims(claims, task, true);
|
|
3895
|
+
requestCheck();
|
|
3214
3896
|
const current = this.need(id, false, true);
|
|
3215
3897
|
if (current.history.length !== task.history.length) throw new PreEffectToolRefusal("task changed while its deliverables were hashed; record them again");
|
|
3216
3898
|
return this.recordLifecycle(by, id, receipt, records);
|
|
3217
3899
|
}
|
|
3218
3900
|
|
|
3219
3901
|
recordLifecycle(by: PeerId, id: unknown, input: unknown, deliverables?: DeliverableRecord[]): Task {
|
|
3902
|
+
requireLocalTaskPeer(by);
|
|
3220
3903
|
const task = this.need(id, false, true);
|
|
3221
3904
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
3222
3905
|
const next = sdlcReceipt(input, task.sdlc, config, VERSION);
|
|
@@ -3257,6 +3940,25 @@ export class Tasks {
|
|
|
3257
3940
|
return this.d.board.list().map(t => ({ id: t.id, after: t.deps ?? [], private: this.isPii(t), receipt: t.sdlc, phase: this.isPii(t) ? undefined : this.workflowView(t), findings: this.isPii(t) ? undefined : this.reviewFindingSets(t), plan: config.mode === "off" || this.isPii(t) ? undefined : { paths: this.planPaths(t), revision: t.history.findLast(h => h.planRevision)?.planRevision?.revision ?? 0, fingerprint: planFingerprint(t.plan ?? {}), accepted: this.validPlanDepartures(t) } }));
|
|
3258
3941
|
}
|
|
3259
3942
|
|
|
3943
|
+
/**
|
|
3944
|
+
* The board links one `readMergeQueue` read used for a subject (#491): the subject, the holders of the PRs in the open set it read (repository names compare without
|
|
3945
|
+
* case, as the queue does), and the tasks those holders and the subject depend on. Nothing else reaches the queue: a proposal, a plan or finding elsewhere, a holder
|
|
3946
|
+
* of a closed or merged PR that nobody depends on, or a receipt in another repository cannot change what it saw. Pass the queue the decision read: its rows are the
|
|
3947
|
+
* open set. Without one (the read failed) every link counts.
|
|
3948
|
+
*/
|
|
3949
|
+
private queueLinks(links: DeliveryTask[], subject: DeliveryTask, queue: MergeQueue | undefined): DeliveryTask[] {
|
|
3950
|
+
if (!queue) return links;
|
|
3951
|
+
const repo = subject.receipt?.repo?.toLowerCase(), open = new Set(queue.rows.map(row => row.pr));
|
|
3952
|
+
// The subject's own dependencies are also reached as a holder of its open PR; naming them here keeps the fence right if that PR were ever absent from the rows.
|
|
3953
|
+
const used = new Set<number>([subject.id, ...subject.after]);
|
|
3954
|
+
for (const link of links) {
|
|
3955
|
+
if (repo === undefined || link.receipt?.repo?.toLowerCase() !== repo || link.receipt.pr === undefined || !open.has(link.receipt.pr)) continue;
|
|
3956
|
+
used.add(link.id);
|
|
3957
|
+
for (const id of link.after) used.add(id);
|
|
3958
|
+
}
|
|
3959
|
+
return links.filter(link => used.has(link.id));
|
|
3960
|
+
}
|
|
3961
|
+
|
|
3260
3962
|
async deliveryQueue(repo: string) {
|
|
3261
3963
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
3262
3964
|
const before = workflowHash(this.d.board.list().map(t => ({ id: t.id, state: t.state, after: t.deps, sdlc: t.sdlc, history: t.history.length })));
|
|
@@ -3289,8 +3991,9 @@ export class Tasks {
|
|
|
3289
3991
|
private readonly mergesInFlight = new Set<number>();
|
|
3290
3992
|
/** #786: who may ask for a guarded merge. The hub's own approval trigger acts as HUB; a peer must be the one configured conductor. */
|
|
3291
3993
|
private mergeAuthority(by: PeerId): boolean {
|
|
3994
|
+
if (isRemotePeerId(by)) return false;
|
|
3292
3995
|
if (by === USER || by === HUB) return true;
|
|
3293
|
-
const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
|
|
3996
|
+
const holders = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => !isRemotePeerId(peer) && Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
|
|
3294
3997
|
return holders.length === 1 && holders[0] === by;
|
|
3295
3998
|
}
|
|
3296
3999
|
/**
|
|
@@ -3321,14 +4024,15 @@ export class Tasks {
|
|
|
3321
4024
|
this.mergesInFlight.add(task.id);
|
|
3322
4025
|
try {
|
|
3323
4026
|
const links = this.deliveryLinks(config), subject = links.find(link => link.id === task.id)!;
|
|
3324
|
-
const queueIdentity = workflowHash({ config, links });
|
|
3325
4027
|
const outcome = await guardedMerge({ mode, subject, state: task.state, taskState: n => this.d.board.get(n)?.state, config, tasks: links, run: this.d.sdlc?.gh ?? sdlcGh, readableMetadata: this.publicMetadataText,
|
|
3326
4028
|
recordMerge: record=>this.recordMergeResult(task.id,by,record,artifactCurrent),
|
|
3327
|
-
beforeMerge:
|
|
4029
|
+
beforeMerge: queue => {
|
|
3328
4030
|
if (!this.mergeAuthority(by)) return "merge authority changed during the merge check: nothing was run";
|
|
3329
|
-
//
|
|
4031
|
+
// Only the links this queue read count: a change to one of those (a dependency artifact, an ordering edge, a relink, a new holder of an open PR) invalidates
|
|
4032
|
+
// that remote evidence, and any other task's change does not. Both sides are projected through the same queue, so a new holder of an open PR shows.
|
|
3330
4033
|
const currentConfig = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
3331
|
-
|
|
4034
|
+
const currentLinks = this.deliveryLinks(currentConfig), currentSubject = currentLinks.find(link => link.id === task.id);
|
|
4035
|
+
return currentSubject && workflowHash({ config: currentConfig, links: this.queueLinks(currentLinks, currentSubject, queue) }) === workflowHash({ config, links: this.queueLinks(links, subject, queue) })
|
|
3332
4036
|
? undefined : "merge queue inputs changed during the merge check: nothing was run";
|
|
3333
4037
|
},
|
|
3334
4038
|
// Read synchronously at dispatch, after the last PR read: the task (state, receipt, privacy, dependencies, findings), the configuration and mode in force, and whether
|
|
@@ -3336,7 +4040,7 @@ export class Tasks {
|
|
|
3336
4040
|
fresh: () => {
|
|
3337
4041
|
const now = this.d.board.get(task.id), cfg = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
3338
4042
|
const current = now && this.deliveryLinks(cfg).find(link => link.id === now.id);
|
|
3339
|
-
return now && current ? { subject: current, state: now.state, config: cfg, mode: opts.dryRun ? "dry-run" : mergeModeFor(cfg, now.sdlc?.repo), allowed: this.mergeAuthority(by) && !this.d.bus.isRecoveryHeld && !this.d.bus.storageError } : undefined;
|
|
4043
|
+
return now && current ? { subject: current, state: now.state, config: cfg, mode: opts.dryRun ? "dry-run" : mergeModeFor(cfg, now.sdlc?.repo), allowed: this.mergeAuthority(by) && !this.d.sweepHeld?.() && !this.d.bus.isRecoveryHeld && !this.d.bus.storageError } : undefined;
|
|
3340
4044
|
} });
|
|
3341
4045
|
if(mode==="on" && !outcome.ran && "refused" in outcome.decision)this.recordMergeRefusal(task.id,by,outcome.decision.refused);
|
|
3342
4046
|
if(outcome.result?.state==="merged") {
|
|
@@ -3436,6 +4140,12 @@ export class Tasks {
|
|
|
3436
4140
|
if (d.path && (!this.nameable(d.path) || matchesPolicy(this.d.cwd, d.path, this.d.piiPaths ?? [], true))) { const { path: _privatePath, ...metadata } = d; return metadata; }
|
|
3437
4141
|
return d;
|
|
3438
4142
|
});
|
|
4143
|
+
// #481 AC12: release interval, lead time from approval to release and hotfix rate, from the shipped releases' recorded history; unknown stays null (never 0).
|
|
4144
|
+
// Releases whose scope holds private work are left out, like the per-release report does.
|
|
4145
|
+
const rowsNow = this.d.board.list();
|
|
4146
|
+
const publicShipped = this.d.board.releases().filter(release => !rowsNow.some(task => task.release === release.id && this.isPii(task)));
|
|
4147
|
+
const metrics = publicShipped.some(release => release.state === "shipped") ? releaseMetrics(publicShipped, rowsNow.filter(task => !this.isPii(task))) : undefined;
|
|
4148
|
+
if (metrics) report.releaseMetrics = metrics;
|
|
3439
4149
|
const trains = this.d.board.releases().filter(release => release.train && ["active", "frozen"].includes(release.state));
|
|
3440
4150
|
if (trains.length) {
|
|
3441
4151
|
const planned = trains.map(release => {
|
|
@@ -3564,11 +4274,12 @@ export class Tasks {
|
|
|
3564
4274
|
const accepted = task.sdlc?.acceptance;
|
|
3565
4275
|
const edit = accepted && !this.isPii(task) && refresh && config.mode !== "off" ? await readSpecEdit(accepted.repo, accepted.issue, read) : undefined;
|
|
3566
4276
|
const integrity = config.mode !== "off" ? publicTestIntegrity(task.sdlc && evidence && !this.isPii(task) ? await readTestIntegrity(task.sdlc, evidence, read) : this.unknownTestIntegrity(this.isPii(task)), this.publicMetadataText) : undefined;
|
|
4277
|
+
const evalResult = task.sdlc && !this.isPii(task) ? await readEvalFor(config, evidence, task.sdlc.repo, read) : undefined;
|
|
3567
4278
|
const current = valid();
|
|
3568
4279
|
if (this.planIdentity(current) !== this.planIdentity(task) || current.history.length !== task.history.length) throw new PreEffectToolRefusal("task changed during lifecycle readback");
|
|
3569
4280
|
this.capturePlanRead(current, evidence);
|
|
3570
4281
|
return sdlcStatus(config, current.sdlc, evidence, this.workflowView(current), edit, this.isPii(current) ? [] : this.reviewFindingSets(current), current.refs.commit,
|
|
3571
|
-
config.mode !== "off" ? this.planReport(this.d.board.get(current.id)!, evidence) : undefined, integrity);
|
|
4282
|
+
config.mode !== "off" ? this.planReport(this.d.board.get(current.id)!, evidence) : undefined, integrity, evalResult);
|
|
3572
4283
|
}
|
|
3573
4284
|
|
|
3574
4285
|
/** #481 S1: only build work starts from an accepted spec. */
|
|
@@ -3595,6 +4306,7 @@ export class Tasks {
|
|
|
3595
4306
|
|
|
3596
4307
|
/** Build entry with a live readback of the spec's body edit time (#481 S1): enforce refuses, advise notifies, off does nothing. */
|
|
3597
4308
|
async specGate(by: PeerId, id: unknown): Promise<void> {
|
|
4309
|
+
requireLocalTaskPeer(by);
|
|
3598
4310
|
const task = this.need(id, false, true);
|
|
3599
4311
|
if (!this.specGated(task) || task.owner !== by && by !== USER) return; // accept reports its own refusals
|
|
3600
4312
|
const accepted = task.sdlc?.acceptance;
|
|
@@ -3605,6 +4317,7 @@ export class Tasks {
|
|
|
3605
4317
|
|
|
3606
4318
|
/** #481 S1: a build stage change by an agent re-reads the body edit time first, as accept does. */
|
|
3607
4319
|
async specStageGate(by: PeerId, id: unknown, input: unknown): Promise<void> {
|
|
4320
|
+
requireLocalTaskPeer(by);
|
|
3608
4321
|
const task = this.need(id, false, true), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
3609
4322
|
if (config.mode === "off" || by === USER || !input || typeof input !== "object" || !("stage" in input)) return;
|
|
3610
4323
|
let next: ReturnType<typeof sdlcReceipt>;
|
|
@@ -3640,6 +4353,8 @@ export class Tasks {
|
|
|
3640
4353
|
return { applicability: "unknown", redFirst: "unknown", fileState: "unknown", modifiedTests: [], blockers: [privateTask ? "test integrity: private evidence unavailable" : "test integrity: current evidence unknown"] };
|
|
3641
4354
|
}
|
|
3642
4355
|
|
|
4356
|
+
/** The eval record the last approval-time lifecycle read produced for a task; consumed by the verdict that follows it. */
|
|
4357
|
+
private readonly evalRecords = new Map<number, string>();
|
|
3643
4358
|
private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string, findings?: readonly ReviewFindingSet[]): Promise<PrEvidence | undefined> {
|
|
3644
4359
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
3645
4360
|
if (config.mode === "off") return;
|
|
@@ -3653,16 +4368,24 @@ export class Tasks {
|
|
|
3653
4368
|
};
|
|
3654
4369
|
const evidence = receipt && !this.isPii(task) ? await readSdlcPr(receipt, read, true) : undefined;
|
|
3655
4370
|
const integrity = publicTestIntegrity(receipt && evidence && !this.isPii(task) ? await readTestIntegrity(receipt, evidence, read) : this.unknownTestIntegrity(this.isPii(task)), this.publicMetadataText);
|
|
4371
|
+
const evalResult = receipt && !this.isPii(task) ? await readEvalFor(config, evidence, receipt.repo, read) : undefined;
|
|
3656
4372
|
const current = this.need(task.id, false, true);
|
|
3657
4373
|
if (config.mode === "enforce" && (current.history.length !== task.history.length || current.owner !== task.owner || current.reviewer !== task.reviewer || current.state !== task.state)) throw new PreEffectToolRefusal(`task #${task.id}: changed during lifecycle readback; retry on current work`);
|
|
3658
4374
|
// Advise may continue on the current task, but evidence from its previous link cannot admit that task's review.
|
|
3659
4375
|
if (current.history.length !== task.history.length || current.owner !== task.owner || current.reviewer !== task.reviewer || current.state !== task.state || this.isPii(current) !== this.isPii(task)) return;
|
|
3660
4376
|
this.capturePlanRead(current, evidence);
|
|
3661
4377
|
const planReport = this.planReport(current, evidence);
|
|
3662
|
-
const status = sdlcStatus(config, receipt, evidence, this.workflowView(current), undefined, this.isPii(current) ? [] : findings ?? this.reviewFindingSets(current), current.refs.commit, planReport, integrity);
|
|
3663
|
-
|
|
4378
|
+
const status = sdlcStatus(config, receipt, evidence, this.workflowView(current), undefined, this.isPii(current) ? [] : findings ?? this.reviewFindingSets(current), current.refs.commit, planReport, integrity, evalResult);
|
|
4379
|
+
// #481 S5: review entry keeps the eval gate's own blockers (its check, and in enforce its evidence gaps), like the premature-review rule does for the ordinary required checks.
|
|
4380
|
+
const evalNames = evalRequiredChecks(config, evidence).filter(name => !config.requiredChecks.includes(name));
|
|
4381
|
+
const evalBlocker = (blocker: string) => evalNames.some(name => blocker.startsWith(`required check ${name}:`)) || evalGapBlockers(status.evalGate).includes(blocker);
|
|
4382
|
+
const blockers = transition === "review" ? [...new Set([...status.blockers.filter(blocker => ["lifecycle stage definition unknown", "issue identity missing", "PR identity missing", "current PR evidence unknown", "PR is not open", "local gate on current head missing or failed"].includes(blocker) || blocker.startsWith("test integrity:") || evalBlocker(blocker)), ...(status.entry ?? []), ...(status.exit ?? [])])] : status.blockers;
|
|
3664
4383
|
if (config.mode === "enforce" && blockers.length) throw new PreEffectToolRefusal(`task #${task.id}: SDLC ${transition} blocked: ${blockers.join("; ")}`);
|
|
3665
|
-
|
|
4384
|
+
// #481 S5: the recorded pass rate and suite version ride the approval note; a code-only PR, an unconfigured gate or an unreported value records nothing.
|
|
4385
|
+
const gate = status.evalGate;
|
|
4386
|
+
if (transition === "approval" && gate?.state === "passed" && gate.passRate !== undefined && gate.suite !== undefined && !this.isPii(task)) this.evalRecords.set(task.id, `Eval gate: ${gate.check} passed at ${evidence!.head.slice(0, 12)}, pass rate ${gate.passRate}, suite ${gate.suite}`);
|
|
4387
|
+
else this.evalRecords.delete(task.id);
|
|
4388
|
+
const advice = [...new Set([...blockers, ...planConformanceBlockers(planReport), ...testIntegrityBlockers(integrity), ...evalAdvice(status.evalGate)])];
|
|
3666
4389
|
if (config.mode === "advise" && advice.length) this.d.notify(`task ${this.publicTitle(task)}: SDLC ${transition} advice: ${this.isPii(task) ? "private lifecycle evidence unknown" : advice.join("; ")}`);
|
|
3667
4390
|
return evidence;
|
|
3668
4391
|
}
|
|
@@ -3746,6 +4469,7 @@ export class Tasks {
|
|
|
3746
4469
|
* announced like one found at assignment (issue #31).
|
|
3747
4470
|
*/
|
|
3748
4471
|
accept(by: PeerId, id: unknown, plan?: unknown, refs?: unknown): Task {
|
|
4472
|
+
requireLocalTaskPeer(by);
|
|
3749
4473
|
const metadata = acceptRefs(refs);
|
|
3750
4474
|
const task = this.need(id, false, true);
|
|
3751
4475
|
this.mine(task, by, "owner", true);
|
|
@@ -3891,9 +4615,10 @@ export class Tasks {
|
|
|
3891
4615
|
* Without `check` this reads the board only: no file is opened, so no row claims `changedSinceReview`. With `check` every file is measured now (one task at a time).
|
|
3892
4616
|
* Every path goes through `publicDeliverables`, so a PII task or a path under `pii.paths` shows id, hash and size only.
|
|
3893
4617
|
*/
|
|
3894
|
-
async deliverableList(opts: { task?: number; approvedOnly?: boolean; check?: boolean } = {}): Promise<DeliverableRow[]> {
|
|
4618
|
+
async deliverableList(opts: { task?: number; approvedOnly?: boolean; check?: boolean; visible?: (task: Task) => boolean } = {}): Promise<DeliverableRow[]> {
|
|
3895
4619
|
const rows: DeliverableRow[] = [];
|
|
3896
|
-
|
|
4620
|
+
// #933: `visible` drops a task before anything is read or measured (an agent's list holds only what hub_task_show shows it).
|
|
4621
|
+
const tasks = (opts.task !== undefined ? [this.d.board.get(opts.task)].filter((task): task is Task => !!task) : this.d.board.list()).filter(task => !opts.visible || opts.visible(task)).sort((a, b) => b.id - a.id);
|
|
3897
4622
|
for (const task of tasks) {
|
|
3898
4623
|
const delivered = latestDeliverables(task.history);
|
|
3899
4624
|
if (!delivered) continue;
|
|
@@ -4081,6 +4806,7 @@ export class Tasks {
|
|
|
4081
4806
|
* duplicate notice is the price of never losing one.
|
|
4082
4807
|
*/
|
|
4083
4808
|
replayHeld(peer: PeerId): void {
|
|
4809
|
+
requireLocalTaskPeer(peer);
|
|
4084
4810
|
const finished = this.d.board.list().filter((u) => (u.state === "approved" || u.state === "in_review") && u.owner && !this.isPii(u));
|
|
4085
4811
|
for (const t of this.d.board.list()) {
|
|
4086
4812
|
if (!OPEN.includes(t.state) || t.owner !== peer || this.isPii(t)) continue;
|
|
@@ -4138,6 +4864,19 @@ export class Tasks {
|
|
|
4138
4864
|
return [head, "The done counts once the files did not change between two calls and the others have stopped.", ...lines, facts?.text ?? ""].filter(Boolean).join("\n");
|
|
4139
4865
|
}
|
|
4140
4866
|
|
|
4867
|
+
/**
|
|
4868
|
+
* #901: which check key produced this result, and how many citation keys the example citation check said were unresolved. The key is the hub's own selection (`Deps.checkKey`, the lookup
|
|
4869
|
+
* `Deps.check` makes), never read from output; without that dependency it is not recorded. The count is read only from the example check's own last line (`citations: N declared keys, M missing`, scripts/check-citations.ts);
|
|
4870
|
+
* a tail without that exact line, or with a number past the bound, records no count: unknown, never zero.
|
|
4871
|
+
*/
|
|
4872
|
+
private checkProvenance(task: Task, tail: string): Pick<HistoryEntry, "checkKey" | "missingKeys"> {
|
|
4873
|
+
let key: string | undefined;
|
|
4874
|
+
try { key = this.d.checkKey?.(task.class, task); } catch { key = undefined; }
|
|
4875
|
+
const lines = tail.split("\n").map(line => line.trim()).filter(Boolean);
|
|
4876
|
+
const match = /^citations: (\d{1,6}) declared keys, (\d{1,6}) missing$/.exec(lines.at(-1) ?? "");
|
|
4877
|
+
return { ...(key ? { checkKey: key } : {}), ...(match ? { missingKeys: Number(match[2]) } : {}) };
|
|
4878
|
+
}
|
|
4879
|
+
|
|
4141
4880
|
private async finishChecked(id: number, by: PeerId, summary: string | undefined, command: string, seen: { events: number; owner: PeerId | null }, withheld: boolean): Promise<void> {
|
|
4142
4881
|
const result = await this.d.runCheck!(command).catch((e: Error) => ({ code: null, timedOut: false, interrupted: false, tail: e.message }));
|
|
4143
4882
|
this.checking.delete(id);
|
|
@@ -4161,12 +4900,12 @@ export class Tasks {
|
|
|
4161
4900
|
this.tell(task, `Task #${id}: its check passed, but the overlapping work or the files changed while it ran (turn-free integration). Call hub_task_done again.`, pii);
|
|
4162
4901
|
return;
|
|
4163
4902
|
}
|
|
4164
|
-
this.d.board.update(id, HUB, "check passed", {}, `${outcome}\n${result.tail}`.trim());
|
|
4903
|
+
this.d.board.update(id, HUB, "check passed", {}, `${outcome}\n${result.tail}`.trim(), this.checkProvenance(task, result.tail));
|
|
4165
4904
|
// The output goes to the reviewer with the done note, not into shared memory: nobody screened it.
|
|
4166
4905
|
await this.complete(this.d.board.get(id)!, by, `${summary ?? ""}\nCheck: ${outcome}`.trim(), undefined, result.tail, withheld);
|
|
4167
4906
|
return;
|
|
4168
4907
|
}
|
|
4169
|
-
this.d.board.update(id, HUB, "check failed", {}, `${outcome}\n${result.tail}`.trim());
|
|
4908
|
+
this.d.board.update(id, HUB, "check failed", {}, `${outcome}\n${result.tail}`.trim(), this.checkProvenance(task, result.tail));
|
|
4170
4909
|
this.cohorts.withdraw(id); // open again: its done intent no longer counts (issue #107)
|
|
4171
4910
|
if (task.owner) this.d.board.recordOutcome(task.owner, task.class, false);
|
|
4172
4911
|
this.contradict(task);
|
|
@@ -4203,7 +4942,8 @@ export class Tasks {
|
|
|
4203
4942
|
this.warmRepository({ ...task, refs: { ...task.refs, ...completedRefs } });
|
|
4204
4943
|
const noWrites = this.noWritesRecorded({ ...task, refs: { ...task.refs, ...completedRefs } });
|
|
4205
4944
|
const capacity = this.capacityBefore(task);
|
|
4206
|
-
const next = this.d.board.update(task.id, by, "done", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...completedRefs } }), state: reviewer ? "in_review" : "approved", ...(selection ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { ...this.planObservation(task), withheld: withheld || undefined, ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection), ...(reviewCost ? { reviewCost } : {}), ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
|
|
4945
|
+
const next = this.d.board.update(task.id, by, "done", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...completedRefs } }), state: reviewer ? "in_review" : "approved", ...(selection ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { ...this.planObservation(task), withheld: withheld || undefined, ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection, { ...task, refs: { ...task.refs, ...completedRefs } }), ...(reviewCost ? { reviewCost } : {}), ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
|
|
4946
|
+
this.queueReviewWritten(next);
|
|
4207
4947
|
this.writePlannerNotices(next, capacity, next.state === "approved");
|
|
4208
4948
|
this.ciWatchers.cancel(next.id); this.heldReviews.delete(next.id); // a newer head or a move ends the old head's watcher (#785 P1b)
|
|
4209
4949
|
this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
|
|
@@ -4323,7 +5063,7 @@ export class Tasks {
|
|
|
4323
5063
|
let quietDrift = false;
|
|
4324
5064
|
const guard = () => {
|
|
4325
5065
|
if (actor !== USER && actor !== task.owner) {
|
|
4326
|
-
const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([peer]) => peer);
|
|
5066
|
+
const holders = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => !isRemotePeerId(peer) && Array.isArray(roles) && roles.includes("conductor")).map(([peer]) => peer);
|
|
4327
5067
|
if (holders.length !== 1 || holders[0] !== actor) throw new (preEffect ? PreEffectToolRefusal : Error)("review admission authority changed");
|
|
4328
5068
|
}
|
|
4329
5069
|
// #694: the board copy seen through the same staged identity link the caller's task carries.
|
|
@@ -4532,6 +5272,7 @@ export class Tasks {
|
|
|
4532
5272
|
|
|
4533
5273
|
/** Host-only admission for a queued review after asynchronous native preparation. */
|
|
4534
5274
|
reviewBindingCurrent(by: PeerId, id: number, binding: { head?: string; flowRevision?: number }): boolean {
|
|
5275
|
+
requireLocalTaskPeer(by);
|
|
4535
5276
|
const task = this.d.board.get(id);
|
|
4536
5277
|
return !!task && task.state === "in_review" && task.reviewer === by && !this.isPii(task)
|
|
4537
5278
|
&& this.reviewRequestRevision(task) === binding.flowRevision && this.reviewHead(task) === binding.head;
|
|
@@ -4539,6 +5280,7 @@ export class Tasks {
|
|
|
4539
5280
|
|
|
4540
5281
|
/** Host-only failure report, bound to the exact review request and its still-current child generation. */
|
|
4541
5282
|
async retryReviewer(id: number, failedPeer: PeerId, failure: { head?: string; flowRevision?: number; cause: "start_failed" | "quota" | "no_verdict" }, current: () => boolean, onReoffered?: () => void): Promise<Task> {
|
|
5283
|
+
requireLocalTaskPeer(failedPeer);
|
|
4542
5284
|
if (!failure || !["start_failed", "quota", "no_verdict"].includes(failure.cause) || !/^[a-z][a-z0-9-]{0,63}$/.test(failedPeer) || !Number.isSafeInteger(failure.flowRevision) || failure.flowRevision! < 1 || failure.head !== undefined && (typeof failure.head !== "string" || text(failure.head) !== failure.head || Buffer.byteLength(failure.head) > 1200 || /\p{Cc}/u.test(failure.head))) throw new PreEffectToolRefusal("invalid reviewer failure binding");
|
|
4543
5285
|
const valid = () => {
|
|
4544
5286
|
const task = this.need(id, false, true);
|
|
@@ -4557,7 +5299,8 @@ export class Tasks {
|
|
|
4557
5299
|
valid();
|
|
4558
5300
|
const note = manual ? "named reviewer recorded no verdict; the person must review"
|
|
4559
5301
|
: selection?.reviewSelection ? this.assignmentNote(`reviewer unavailable: ${failure.cause}`, selection) : "no declared reviewer pool can receive the fallback; the person must review";
|
|
4560
|
-
const next = this.d.board.update(task.id, HUB, "reviewer unavailable", { reviewer }, note, { ...this.reviewMetadata(selection), ...(reviewer === USER ? { reviewBlocked: true } : {}), reviewerFailure: { peer: failedPeer, cause: failure.cause, ...(failure.head ? { head: failure.head } : {}) } }, task.history.length);
|
|
5302
|
+
const next = this.d.board.update(task.id, HUB, "reviewer unavailable", { reviewer }, note, { ...this.reviewMetadata(selection, task), ...(reviewer === USER ? { reviewBlocked: true } : {}), reviewerFailure: { peer: failedPeer, cause: failure.cause, ...(failure.head ? { head: failure.head } : {}) } }, task.history.length);
|
|
5303
|
+
this.queueReviewWritten(next);
|
|
4561
5304
|
this.releaseNotice(`task ${this.publicTitle(next)}: former review request withdrawal incomplete`, () => this.withdrawRoleRequest(task, next, "reviewer"));
|
|
4562
5305
|
let offered = false;
|
|
4563
5306
|
if (reviewer === USER) {
|
|
@@ -4637,6 +5380,7 @@ export class Tasks {
|
|
|
4637
5380
|
}
|
|
4638
5381
|
|
|
4639
5382
|
async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown, head?: unknown, coverage?: unknown, findings?: unknown): Promise<Task> {
|
|
5383
|
+
const requestCheck = this.requestCheck(by);
|
|
4640
5384
|
const authority = by === USER ? undefined : this.d.reviewAuthority?.(by, Number(id));
|
|
4641
5385
|
let findingGuard: ((task: Task) => void) | undefined;
|
|
4642
5386
|
// #520: what the coverage was checked against, pinned through every await: the commit and the lifecycle receipt (the
|
|
@@ -4644,6 +5388,7 @@ export class Tasks {
|
|
|
4644
5388
|
let covered: { commit: string | undefined; receipt: string } | undefined;
|
|
4645
5389
|
let conformanceIdentity: string | undefined;
|
|
4646
5390
|
const valid = (): Task => {
|
|
5391
|
+
requestCheck();
|
|
4647
5392
|
const refused = authority?.();
|
|
4648
5393
|
if (refused) throw new PreEffectToolRefusal(refused); // rechecked after every await, right before each write
|
|
4649
5394
|
const t = this.need(id, false, true);
|
|
@@ -4749,7 +5494,12 @@ export class Tasks {
|
|
|
4749
5494
|
}
|
|
4750
5495
|
}
|
|
4751
5496
|
}
|
|
4752
|
-
if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") {
|
|
5497
|
+
if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") {
|
|
5498
|
+
this.evalRecords.delete(task.id);
|
|
5499
|
+
await this.lifecycleGate(task, "approval", reviewedHead, proposedSets(task)); task = valid();
|
|
5500
|
+
const record = this.evalRecords.get(task.id); this.evalRecords.delete(task.id);
|
|
5501
|
+
if (record) note = `${note ?? ""}\n${record}`.trim();
|
|
5502
|
+
}
|
|
4753
5503
|
let candidate: Extract<CandidateInput, { kind: "fail" }> | undefined;
|
|
4754
5504
|
if (verdict === "changes_requested" && !evidenceOnly && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
|
|
4755
5505
|
const input: CandidateInput = { project: this.d.store?.project ?? this.d.project, kind: "fail", approach: this.publicTitle(task), symptom: items.join("\n"),
|
|
@@ -4792,12 +5542,14 @@ export class Tasks {
|
|
|
4792
5542
|
this.autoMerge(next, this.mirrorVerdict(next, by, "approved", reviewedHead ?? next.sdlc?.reviewedHead, note, withheld));
|
|
4793
5543
|
// A proposal storage failure cannot roll back the saved verdict; another matching verdict retries it.
|
|
4794
5544
|
this.releaseNotice(`task #${next.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(next, by, parsed, withheld));
|
|
5545
|
+
this.releaseNotice(`task #${next.id}: review saved but recurring-criterion proposal unavailable`, () => this.proposeCriterionCandidates(next, by, withheld));
|
|
4795
5546
|
return next;
|
|
4796
5547
|
}
|
|
4797
5548
|
this.ciWatchers.cancel(task.id); this.heldReviews.delete(task.id);
|
|
4798
5549
|
const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: evidenceOnly ? task.rejections : task.rejections + 1 }, note, flag);
|
|
4799
5550
|
void this.mirrorVerdict(rejected, by, "changes_requested", reviewedHead ?? rejected.sdlc?.reviewedHead, note, withheld);
|
|
4800
5551
|
if (!evidenceOnly) this.releaseNotice(`task #${rejected.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(rejected, by, parsed, withheld));
|
|
5552
|
+
if (!evidenceOnly) this.releaseNotice(`task #${rejected.id}: review saved but recurring-criterion proposal unavailable`, () => this.proposeCriterionCandidates(rejected, by, withheld));
|
|
4801
5553
|
if (candidate && !pii && !this.d.privateTurn?.(by) && this.candidateTextAllowed(JSON.stringify({ candidate, refs: rejected.refs, plan: rejected.plan }), rejected)) {
|
|
4802
5554
|
try { this.d.memoryCheck?.(); this.d.candidates?.put({ ...candidate, evidenceRefs: [...candidate.evidenceRefs, `review:${rejected.id}:${rejected.history.length}`] }, "public"); } catch { /* a saved review remains authoritative if the optional sidecar fails */ }
|
|
4803
5555
|
}
|
|
@@ -4837,7 +5589,7 @@ export class Tasks {
|
|
|
4837
5589
|
* the review or the task reaches it; a PII task's notice is private. With no conductor the person sees one console notice instead.
|
|
4838
5590
|
*/
|
|
4839
5591
|
private tellConductorEvidenceOnly(task: Task, reviewer: PeerId): void {
|
|
4840
|
-
const conductors = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor).filter(actor => actor !== reviewer);
|
|
5592
|
+
const conductors = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => !isRemotePeerId(peer) && Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor).filter(actor => actor !== reviewer);
|
|
4841
5593
|
const text = evidenceOnlyNotice(task.id, reviewer);
|
|
4842
5594
|
if (!conductors.length) { this.releaseNotice(text); return; }
|
|
4843
5595
|
this.releaseNotice(`task #${task.id}: could not tell the conductor a code-scope review was evidence-only`, () => {
|
|
@@ -4847,6 +5599,7 @@ export class Tasks {
|
|
|
4847
5599
|
|
|
4848
5600
|
/** Task-level escalation: the next attached peer in the class's escalate_to takes over, with the history. */
|
|
4849
5601
|
async escalate(by: PeerId, id: unknown, why = "by hand", reason: TaskMoveReason | undefined = by === HUB ? undefined : "manual", preEffect = false): Promise<Task> {
|
|
5602
|
+
requireLocalTaskPeer(by);
|
|
4850
5603
|
let task = this.need(id, true, preEffect);
|
|
4851
5604
|
const list = this.d.routing().classes[task.class]?.escalate_to ?? [];
|
|
4852
5605
|
if (task.state === "changes_requested") task = this.d.board.update(task.id, HUB, "reopened", { state: "in_progress" });
|
|
@@ -4908,8 +5661,33 @@ export class Tasks {
|
|
|
4908
5661
|
});
|
|
4909
5662
|
}
|
|
4910
5663
|
|
|
5664
|
+
/** Preserve explicit intent across a policy reload; recheck conductor authority after every await. */
|
|
5665
|
+
private explicitQueueAssignment(by: PeerId, role: Exclude<TaskAssignmentRole, "planner">, preEffect: boolean): { role: "owner" | "reviewer"; current: () => void } | undefined {
|
|
5666
|
+
if (by === USER) return { role, current: () => {} };
|
|
5667
|
+
const current = () => {
|
|
5668
|
+
const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([peer]) => peer);
|
|
5669
|
+
return holders.length === 1 && holders[0] === by && this.d.bus.isRostered(by);
|
|
5670
|
+
};
|
|
5671
|
+
if (!current()) return undefined;
|
|
5672
|
+
return { role, current: () => { if (!current()) throw new (preEffect ? PreEffectToolRefusal : Error)("only the current configured conductor may override assignment admission"); } };
|
|
5673
|
+
}
|
|
5674
|
+
|
|
5675
|
+
private configuredManualConductor(by: PeerId): boolean {
|
|
5676
|
+
if (by === USER || by === HUB || isRemotePeerId(by)) return false;
|
|
5677
|
+
const holders = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => !isRemotePeerId(peer) && Array.isArray(roles) && roles.includes("conductor")).map(([peer]) => peer);
|
|
5678
|
+
return holders.length === 1 && holders[0] === by;
|
|
5679
|
+
}
|
|
5680
|
+
private conductorManualCheck(by: PeerId, preEffect: boolean): () => void {
|
|
5681
|
+
return () => {
|
|
5682
|
+
if (!this.configuredManualConductor(by) || !this.d.bus.isRostered(by)) throw new (preEffect ? PreEffectToolRefusal : Error)("only the console user or the current configured conductor may unassign a task role");
|
|
5683
|
+
};
|
|
5684
|
+
}
|
|
5685
|
+
|
|
4911
5686
|
/** Caller authority is checked by the daemon; keep the real actor in task history. */
|
|
4912
5687
|
async assignTo(id: unknown, peer: PeerId, by: PeerId = USER, preEffect = false, role: TaskAssignmentRole = "owner", expectedFlowRevision?: number): Promise<Task> {
|
|
5688
|
+
requireLocalTaskPeer(by); requireLocalTaskPeer(peer);
|
|
5689
|
+
const conductorManual = this.configuredManualConductor(by), manualCurrent = conductorManual ? this.conductorManualCheck(by, preEffect) : undefined;
|
|
5690
|
+
manualCurrent?.();
|
|
4913
5691
|
if (role !== "owner" && role !== "reviewer" && role !== "planner") throw new (preEffect ? PreEffectToolRefusal : Error)("role must be owner, reviewer or planner");
|
|
4914
5692
|
if (role === "planner") {
|
|
4915
5693
|
if (by !== USER) throw new PreEffectToolRefusal("only the person may name a task planner");
|
|
@@ -4923,9 +5701,11 @@ export class Tasks {
|
|
|
4923
5701
|
return next;
|
|
4924
5702
|
}
|
|
4925
5703
|
if (role === "reviewer" && by !== USER) {
|
|
4926
|
-
const conductors = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
|
|
5704
|
+
const conductors = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => !isRemotePeerId(peer) && Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
|
|
4927
5705
|
if (conductors.length !== 1 || conductors[0] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)("only the console user or the configured conductor may name a reviewer");
|
|
4928
5706
|
}
|
|
5707
|
+
const queueAssignment = this.explicitQueueAssignment(by, role, preEffect);
|
|
5708
|
+
queueAssignment?.current();
|
|
4929
5709
|
const task = this.need(id, role === "owner", preEffect);
|
|
4930
5710
|
this.checkFlowRevision(task, expectedFlowRevision, preEffect);
|
|
4931
5711
|
if (!this.d.bus.isRostered(peer)) throw new (preEffect ? PreEffectToolRefusal : Error)(`${peer} is off roster; add it to the roster before assigning work`);
|
|
@@ -4933,20 +5713,23 @@ export class Tasks {
|
|
|
4933
5713
|
if (role === "reviewer") {
|
|
4934
5714
|
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`);
|
|
4935
5715
|
this.ready(task, preEffect);
|
|
4936
|
-
const a = assign(task, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class, task) });
|
|
5716
|
+
const a = assign(task, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class, task), ...(queueAssignment ? { queueOverride: queueAssignment.role } : {}) });
|
|
4937
5717
|
if (a.reviewer !== peer) throw new (preEffect ? PreEffectToolRefusal : Error)(`cannot name ${peer} as reviewer: ${a.trace.filter(line => line.includes("reviewer") || line.includes("pii")).join("; ")}`);
|
|
4938
5718
|
const admission = task.state === "in_review" ? await this.reviewRequestAdmission(task, undefined, preEffect, by) : undefined;
|
|
4939
5719
|
// Every runtime authority check precedes the final write, including revocations during the metadata await.
|
|
5720
|
+
manualCurrent?.();
|
|
5721
|
+
queueAssignment?.current();
|
|
4940
5722
|
const current = this.need(task.id, false, preEffect);
|
|
4941
5723
|
if (current.history.length !== task.history.length) throw new (preEffect ? PreEffectToolRefusal : Error)("task changed during reviewer admission");
|
|
4942
5724
|
if (by !== USER) {
|
|
4943
|
-
const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
|
|
5725
|
+
const holders = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => !isRemotePeerId(peer) && Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
|
|
4944
5726
|
if (holders.length !== 1 || holders[0] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)("only the console user or the configured conductor may name a reviewer");
|
|
4945
5727
|
}
|
|
4946
5728
|
if (!this.d.bus.isRostered(peer)) throw new (preEffect ? PreEffectToolRefusal : Error)(`${peer} is off roster`);
|
|
4947
5729
|
this.requireCapabilities(current, peer, role, preEffect);
|
|
4948
|
-
const eligible = assign(current, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(current.owner ? { notReviewer: current.owner } : {}), ...this.weights(current.class, current) });
|
|
5730
|
+
const eligible = assign(current, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(current.owner ? { notReviewer: current.owner } : {}), ...this.weights(current.class, current), ...(queueAssignment ? { queueOverride: queueAssignment.role } : {}) });
|
|
4949
5731
|
if (eligible.reviewer !== peer) throw new (preEffect ? PreEffectToolRefusal : Error)("reviewer is no longer eligible");
|
|
5732
|
+
queueAssignment?.current();
|
|
4950
5733
|
const next = this.d.board.update(task.id, by, "reviewer changed", { reviewer: peer }, this.assignmentNote(`named reviewer ${peer}`, a), { reason: "manual" }, expectedFlowRevision);
|
|
4951
5734
|
if (next.state === "in_review" && task.reviewer && task.reviewer !== peer) {
|
|
4952
5735
|
this.withdrawRoleRequest(task, next, "reviewer");
|
|
@@ -4955,15 +5738,23 @@ export class Tasks {
|
|
|
4955
5738
|
return next;
|
|
4956
5739
|
}
|
|
4957
5740
|
// Work that waits is handed to nobody yet (issue #207): the peer becomes its reserved owner instead.
|
|
4958
|
-
if (!this.waitsFor(task).length) return this.assignOwner(task, by, { candidates: [peer], namedOwner: peer, event: "reassigned", reason: "manual", override: by === USER, expectedFlowRevision, refuseNamed: preEffect ? "pre" : "plain" });
|
|
4959
|
-
|
|
5741
|
+
if (!this.waitsFor(task).length) return this.assignOwner(task, by, { candidates: [peer], namedOwner: peer, event: "reassigned", reason: "manual", override: by === USER, expectedFlowRevision, refuseNamed: preEffect ? "pre" : "plain", ...(conductorManual ? { conductorManual: true as const } : {}), ...(queueAssignment ? { queueOverride: "owner" as const } : {}), current: () => { manualCurrent?.(); queueAssignment?.current(); } });
|
|
5742
|
+
manualCurrent?.();
|
|
5743
|
+
queueAssignment?.current();
|
|
5744
|
+
const next = this.d.board.update(task.id, by, "reserved", { reserved: peer }, `for ${peer}`, { reason: "manual", ...(conductorManual ? { conductorManual: true as const } : {}) }, expectedFlowRevision);
|
|
4960
5745
|
this.releaseNotice(`task ${this.publicTitle(next)} reserved for ${peer} by ${by}; it is offered to ${peer} first once what it waits for is approved`);
|
|
4961
5746
|
return next;
|
|
4962
5747
|
}
|
|
4963
5748
|
|
|
4964
|
-
/**
|
|
5749
|
+
/** Explicit removal without routing a replacement; host history preserves manual intent across role changes. */
|
|
4965
5750
|
async unassign(id: unknown, by: PeerId = USER, preEffect = false, role: TaskAssignmentRole = "owner", expectedFlowRevision?: number): Promise<Task> {
|
|
4966
|
-
|
|
5751
|
+
const authority = by === USER ? undefined : this.conductorManualCheck(by, preEffect);
|
|
5752
|
+
const current = authority && (() => {
|
|
5753
|
+
authority();
|
|
5754
|
+
if (this.d.privateTurn?.(by)) throw new (preEffect ? PreEffectToolRefusal : Error)("conductor task mutations are unavailable during a private turn");
|
|
5755
|
+
if (this.d.cancellationHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) throw new (preEffect ? PreEffectToolRefusal : Error)("recovery or shutdown is holding task writes");
|
|
5756
|
+
});
|
|
5757
|
+
current?.();
|
|
4967
5758
|
if (role !== "owner" && role !== "reviewer") throw new (preEffect ? PreEffectToolRefusal : Error)("role must be owner or reviewer");
|
|
4968
5759
|
const task = this.need(id, false, preEffect);
|
|
4969
5760
|
this.checkFlowRevision(task, expectedFlowRevision, preEffect);
|
|
@@ -4971,7 +5762,8 @@ export class Tasks {
|
|
|
4971
5762
|
if (role === "owner") {
|
|
4972
5763
|
if (!OPEN.includes(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its owner cannot be unassigned`);
|
|
4973
5764
|
if (!task.owner && !task.reserved) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: no owner or owner reservation to unassign`);
|
|
4974
|
-
|
|
5765
|
+
current?.();
|
|
5766
|
+
const next = this.d.board.update(task.id, by, "unassigned", { owner: null, reserved: null, ...(task.state === "proposed" ? {} : { state: "proposed" as const }) }, `${by === USER ? "person" : "conductor"} unassigned ${[task.owner, task.reserved].filter(Boolean).join(" and ")}`, { reason: "manual", ...(current ? { conductorManual: true as const } : {}) }, expectedFlowRevision);
|
|
4975
5767
|
this.cohorts.leave(task.id);
|
|
4976
5768
|
this.withdrawRoleRequest(task, next, "owner");
|
|
4977
5769
|
return next;
|
|
@@ -4979,7 +5771,8 @@ export class Tasks {
|
|
|
4979
5771
|
if (!OPEN.includes(task.state) && task.state !== "in_review") throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its reviewer cannot be unassigned`);
|
|
4980
5772
|
if (!task.reviewer) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: no reviewer to unassign`);
|
|
4981
5773
|
if (task.reviewer === USER && this.isPii(task)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: the human reviewer is required for PII work`);
|
|
4982
|
-
|
|
5774
|
+
current?.();
|
|
5775
|
+
const next = this.d.board.update(task.id, by, "reviewer changed", { reviewer: null }, `${by === USER ? "person" : "conductor"} unassigned reviewer ${task.reviewer}`, { reason: "manual", ...(current ? { conductorManual: true as const } : {}) }, expectedFlowRevision);
|
|
4983
5776
|
this.withdrawRoleRequest(task, next, "reviewer");
|
|
4984
5777
|
return next;
|
|
4985
5778
|
}
|
|
@@ -4989,11 +5782,13 @@ export class Tasks {
|
|
|
4989
5782
|
* the usual constraints. Tasks it was reviewing get another reviewer. Moves are reported, never taken back automatically.
|
|
4990
5783
|
*/
|
|
4991
5784
|
async reassignForPause(peer: PeerId, context: string | undefined, urgentOnly = false): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
|
|
5785
|
+
requireLocalTaskPeer(peer);
|
|
4992
5786
|
return this.reassignWork(peer, context, urgentOnly, false);
|
|
4993
5787
|
}
|
|
4994
5788
|
|
|
4995
5789
|
/** Read-only preview using the same assignment constraints as the roster relay. */
|
|
4996
5790
|
rosterPreview(peer: PeerId): { id: number; title: string; role: "owner" | "reviewer"; to: PeerId | null }[] {
|
|
5791
|
+
requireLocalTaskPeer(peer);
|
|
4997
5792
|
const routing = this.d.routing();
|
|
4998
5793
|
return this.d.board.list().filter(task => !isClosedTaskState(task.state) && (task.owner === peer || task.reviewer === peer)).map(task => {
|
|
4999
5794
|
const owner = task.owner === peer;
|
|
@@ -5007,6 +5802,7 @@ export class Tasks {
|
|
|
5007
5802
|
|
|
5008
5803
|
/** A roster removal offers work through the quota relay's constraints, preserving assignments nobody can take. */
|
|
5009
5804
|
async reassignForRoster(peer: PeerId, current?: () => void): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
|
|
5805
|
+
requireLocalTaskPeer(peer);
|
|
5010
5806
|
return this.reassignWork(peer, undefined, false, true, current);
|
|
5011
5807
|
}
|
|
5012
5808
|
|
|
@@ -5055,7 +5851,8 @@ export class Tasks {
|
|
|
5055
5851
|
continue;
|
|
5056
5852
|
}
|
|
5057
5853
|
current?.();
|
|
5058
|
-
const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, this.assignmentNote(why, a), this.reviewMetadata(a));
|
|
5854
|
+
const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, this.assignmentNote(why, a), this.reviewMetadata(a, task));
|
|
5855
|
+
this.queueReviewWritten(next);
|
|
5059
5856
|
moved.push({ id: task.id, title: this.publicTitle(task), to: reviewer, role: "reviewer" });
|
|
5060
5857
|
current?.();
|
|
5061
5858
|
if (next.state === "in_review" && reviewer && reviewer !== USER) {
|
|
@@ -5090,11 +5887,13 @@ export class Tasks {
|
|
|
5090
5887
|
|
|
5091
5888
|
/** Candidate only: the person remains the sole acceptance authority. */
|
|
5092
5889
|
async correct(by: PeerId, input: unknown): Promise<string> {
|
|
5890
|
+
const requestCheck = this.requestCheck(by);
|
|
5093
5891
|
const args = correctionInput(input);
|
|
5094
5892
|
const st = this.d.store;
|
|
5095
5893
|
if (!st || !this.d.candidates) throw new PreEffectToolRefusal("memory correction unavailable");
|
|
5096
5894
|
const screen: MemoryScreen = rows => this.d.candidateScreen?.(rows) ?? withoutPii(rows, this.d.routing(), this.d.cwd, this.d.piiPaths ?? []);
|
|
5097
5895
|
const valid = () => {
|
|
5896
|
+
requestCheck();
|
|
5098
5897
|
this.d.memoryCheck?.();
|
|
5099
5898
|
if (this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("memory correction unavailable during a private turn");
|
|
5100
5899
|
if (!this.candidateTextAllowed(JSON.stringify(args))) throw new PreEffectToolRefusal("memory correction withheld by privacy policy");
|
|
@@ -5169,6 +5968,7 @@ export class Tasks {
|
|
|
5169
5968
|
}
|
|
5170
5969
|
/** A person links a board task of this project to a long-term task (`ahub goal link`). */
|
|
5171
5970
|
linkGoal(by: PeerId, id: unknown, ref: unknown): string {
|
|
5971
|
+
requireLocalTaskPeer(by);
|
|
5172
5972
|
const task = this.d.board.get(Number(id));
|
|
5173
5973
|
if (!task) throw new Error(`no task #${String(id).slice(0, 12)}`);
|
|
5174
5974
|
const goal = this.goalFor(by, ref);
|