@staix/agent-hub 0.20.0 → 0.22.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 +66 -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 +3 -3
- package/docs/agent-notes/daemon.md +17 -8
- 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 +39 -4
- package/docs/events.md +2 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +610 -62
- package/docs/profiles.md +13 -1
- package/docs/quickstart.md +18 -9
- package/docs/research.md +7 -4
- package/docs/sdlc.md +54 -10
- package/docs/security.md +396 -34
- package/docs/smoke.md +133 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +1101 -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/2026-10-30-0.22.0.md +49 -0
- package/docs/verification/2026-10-30-445-capability-probes.md +172 -0
- package/docs/verification/2026-10-30-bus-delivery-operational-settings.md +77 -0
- package/docs/verification/2026-10-30-jcode-ac4.md +24 -0
- package/docs/verification/2026-10-30-jcode-ac6.md +52 -0
- package/docs/verification/2026-10-30-recovery-wait-settings.md +141 -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 +29 -16
- package/src/cli/identity.ts +25 -10
- package/src/cli/init.ts +34 -7
- package/src/cli/intake-output.ts +33 -0
- package/src/cli/learn-output.ts +38 -0
- package/src/cli/main.ts +334 -73
- package/src/cli/output.ts +41 -1
- package/src/cli/recovery-package.ts +18 -1
- package/src/cli/recovery-waits.ts +26 -0
- 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 +82 -0
- package/src/cli/smoke-pty.ts +153 -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 +112 -28
- package/src/cli/upgrade.ts +68 -8
- package/src/hub/ask.ts +76 -12
- package/src/hub/board.ts +132 -22
- package/src/hub/bus.ts +416 -65
- 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 +34 -6
- 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 +1209 -183
- package/src/hub/deliverable-files.ts +8 -2
- package/src/hub/delivery-journal.ts +200 -73
- 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 +117 -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 +154 -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 +27 -2
- 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 +987 -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,27 @@ 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, overridesDiff, parseCandidateCard, selectGroups, type CriterionGroup } from "./learning.ts";
|
|
39
|
+
/** #514b: how a closed candidate card says what the person decided; `learnCandidates` reads them back. */
|
|
40
|
+
const LEARN_ACCEPTED = "candidate accepted", LEARN_REJECTED = "candidate rejected";
|
|
41
|
+
/** What the person decided on a candidate card, read from its cancellation: only an entry the person wrote counts, so no other note can pass for a verdict. Undefined while it is open or closed for another reason. */
|
|
42
|
+
const learnVerdict = (card: Pick<Task, "state" | "history">): "accepted" | "rejected" | undefined => {
|
|
43
|
+
if (card.state !== "cancelled") return undefined;
|
|
44
|
+
const closed = card.history.findLast(entry => entry.event === "cancelled");
|
|
45
|
+
if (!closed || closed.by !== USER) return undefined;
|
|
46
|
+
return closed.note?.startsWith(LEARN_ACCEPTED) ? "accepted" : closed.note?.startsWith(LEARN_REJECTED) ? "rejected" : undefined;
|
|
47
|
+
};
|
|
31
48
|
import { approvedDeliverables, latestDeliverables, parseDeliverableClaims, publicDeliverables, type DeliverableRecord, type DeliverableStatus } from "./deliverables.ts";
|
|
32
49
|
import { checkDeliverables, DeliverableRefusal, hashDeliverables, vetDeliverables, type DeliverablePorts } from "./deliverable-files.ts";
|
|
33
50
|
import { isAbsolute, relative } from "node:path";
|
|
34
51
|
import type { Briefs, NativeItem } from "../memory/brief.ts";
|
|
35
52
|
import type { MemoryClient } from "../memory/client.ts";
|
|
36
53
|
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";
|
|
54
|
+
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
55
|
import type { Bus } from "./bus.ts";
|
|
39
56
|
import { HUB, newEnvelope, NOTE_KINDS, noteLine, sanitize, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
|
|
40
57
|
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";
|
|
58
|
+
import { assign, isRemotePeerId, detectSignals, peerEntry, predictSplit, relayCandidates, type Assignment, type InstanceRoutingOptions, type PeerCapabilities, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
|
|
42
59
|
import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
|
|
43
60
|
import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
|
|
44
61
|
import { realPath } from "./project.ts";
|
|
@@ -52,6 +69,10 @@ import { plannerGrooming, nextSweep, taskSweepConfig, type TaskSweepConfig, type
|
|
|
52
69
|
import { CiWatchers, pollCi, type CiOutcome, type CiWatch } from "./ci-watch.ts";
|
|
53
70
|
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
71
|
|
|
72
|
+
function requireLocalTaskPeer(peer: unknown): void {
|
|
73
|
+
if (isRemotePeerId(peer)) throw new PreEffectToolRefusal("remote peers have no local task authority");
|
|
74
|
+
}
|
|
75
|
+
|
|
55
76
|
export type TaskAssignmentRole = "owner" | "reviewer" | "planner";
|
|
56
77
|
|
|
57
78
|
export interface ReleaseWindow { at: number; host: boolean; quota: boolean; idle: boolean; reasons: string[]; current?: () => boolean; close?: () => Promise<void>; }
|
|
@@ -68,6 +89,10 @@ export interface TasksDeps {
|
|
|
68
89
|
board: Board;
|
|
69
90
|
bus: Bus;
|
|
70
91
|
routing: () => Routing;
|
|
92
|
+
/** #800: normalized machine-local admission policy; absent or both zero preserve the old queue behaviour. */
|
|
93
|
+
queueBackpressure?: () => QueueBackpressurePolicy;
|
|
94
|
+
/** Host-only native context snapshots, separate from checkpoint notification and quota. */
|
|
95
|
+
contextAdmission?: () => ContextAdmission;
|
|
71
96
|
/** Startup-bound project declarations; routing never discovers instances from peer names. */
|
|
72
97
|
instances?: () => InstancesConfig;
|
|
73
98
|
/** Host proof of each live extra runtime's actual isolated execution root; config paths alone are not proof. */
|
|
@@ -120,6 +145,11 @@ export interface TasksDeps {
|
|
|
120
145
|
reviewAuthority?: (by: PeerId, task: number) => (() => string | undefined) | undefined;
|
|
121
146
|
/** Host-captured current-child authority, rechecked before an admitted async tool first commits effects. */
|
|
122
147
|
requestAuthority?: (by: PeerId) => (() => string | undefined) | undefined;
|
|
148
|
+
/**
|
|
149
|
+
* #933: whether `by` runs this call inside an admitted, audited conductor operation. The host rechecks the role on every call and throws
|
|
150
|
+
* its authority refusal when it ended; a peer outside such a call gets `false`. Host-only: never read from configuration or a tool.
|
|
151
|
+
*/
|
|
152
|
+
operational?: (by: PeerId) => boolean;
|
|
123
153
|
memory?: MemoryClient;
|
|
124
154
|
/** Captured native profile fence at our memory operation boundaries; no board mutation. */
|
|
125
155
|
memoryCheck?: () => void;
|
|
@@ -129,6 +159,8 @@ export interface TasksDeps {
|
|
|
129
159
|
notify: (line: string) => void;
|
|
130
160
|
/** The completion check configured for a class, if any (local config only; issue #7). */
|
|
131
161
|
check?: (cls: TaskClass, task?: Task) => string | undefined;
|
|
162
|
+
/** #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. */
|
|
163
|
+
checkKey?: (cls: TaskClass, task?: Task) => string | undefined;
|
|
132
164
|
/** Runs one check; the hub runs them one at a time. */
|
|
133
165
|
runCheck?: (command: string) => Promise<{ code: number | null; timedOut: boolean; interrupted?: boolean; tail: string }>;
|
|
134
166
|
/** Hands a saved note to every other peer; it rides on their next delivery. */
|
|
@@ -159,6 +191,8 @@ export interface TasksDeps {
|
|
|
159
191
|
roles?: Record<string, string[]>;
|
|
160
192
|
/** Optional: name a class for a task proposed without one. `onCampus` says whether the model call stays on campus. */
|
|
161
193
|
triage?: { classify: (title: string, detail: string) => Promise<TaskClass | undefined>; onCampus: () => Promise<boolean> };
|
|
194
|
+
/** #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. */
|
|
195
|
+
intake?: { split: (system: string, user: string, opts: { campusOnly: boolean }) => Promise<string | undefined>; partsSchema: () => unknown; sourceKnown: (id: string) => boolean };
|
|
162
196
|
/** The PII screen (issue #198): a closed verdict from a model on this machine or on campus, `unknown` on any failure. */
|
|
163
197
|
piiScreen?: (text: string) => Promise<PiiVerdict>;
|
|
164
198
|
/** Each screen verdict, for the record: item, label, source and category, never the text. */
|
|
@@ -191,27 +225,43 @@ export interface TasksDeps {
|
|
|
191
225
|
/** A cohort formed, changed or was lifted (issue #107), for the record: the benchmark's treatment check reads it. */
|
|
192
226
|
recordCohort?: (cohort: { id: number; event: "formed" | "joined" | "lifted"; silent: boolean; tasks: number[]; owners: PeerId[] }) => void;
|
|
193
227
|
sweep?: TaskSweepConfig;
|
|
194
|
-
/**
|
|
228
|
+
/** #798 D1: the project's declared submodule paths, project-relative. Absent: none are known, so no notice. */
|
|
229
|
+
submodules?: () => string[];
|
|
230
|
+
/** Shutdown or coordinator work (recovery) holds task sweeps, and a guarded merge at its dispatch (#491). */
|
|
195
231
|
sweepHeld?: () => boolean;
|
|
196
232
|
/** Consecutive changes_requested verdicts that move a task to another peer (`tasks.escalate_after`, #488). */
|
|
197
233
|
escalateAfter?: () => number;
|
|
234
|
+
/** #489 H1: current host configuration, read at each planner-notice flush; no task/request override. */
|
|
235
|
+
groomFyiWindowMin?: () => number;
|
|
236
|
+
/** #489 H2: current host configuration, read at each use (no cache, no task/request override); absent is the constant each replaces. */
|
|
237
|
+
demoteAt?: () => number;
|
|
238
|
+
demotionHalfLifeH?: () => number;
|
|
239
|
+
contradictionWindowDays?: () => number;
|
|
240
|
+
rescreenMax?: () => number;
|
|
241
|
+
splitMinObservations?: () => number;
|
|
242
|
+
splitMaxFailureShare?: () => number;
|
|
198
243
|
}
|
|
199
244
|
|
|
200
245
|
/** The `tasks.escalate_after` default (#488): the third consecutive changes_requested escalates. */
|
|
201
246
|
export const ESCALATE_AFTER = 3;
|
|
202
247
|
// Demotion (issue #36): a failure counts half after a day. A peer is demoted for a class once its decayed failures
|
|
203
248
|
// 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;
|
|
249
|
+
// #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).
|
|
250
|
+
export const DEMOTION_HALF_LIFE_MS = 24 * 3_600_000;
|
|
251
|
+
export const DEMOTE_AT = 1.5;
|
|
252
|
+
/** An approval counts as contradicted when work on the same places fails within this window (issue #35); `tasks.contradiction_window_days` sets it. */
|
|
253
|
+
export const CONTRADICTION_WINDOW_MS = 7 * 86_400_000;
|
|
209
254
|
const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
|
|
255
|
+
const QUEUE_PRESSURE_CODES: readonly QueueBackpressureCode[] = ["queue-count", "queue-age", "queue-age-unknown"];
|
|
256
|
+
const isQueuePressureCode = (code: string): code is QueueBackpressureCode => (QUEUE_PRESSURE_CODES as readonly string[]).includes(code);
|
|
257
|
+
const QUEUE_RECHECK_PAGE = 32;
|
|
258
|
+
interface QueuePressurePass { ownerAfter: number; reviewAfter: number; ownerDone: boolean; reviewDone: boolean; retained: boolean; generation: number }
|
|
210
259
|
/** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
|
|
211
260
|
const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep", "memory reference started"]);
|
|
212
261
|
QUIET_EVENTS.add("ready held"); // #655: the hub's note that a ready task waits for its reserved owner's WIP slot.
|
|
213
262
|
QUIET_EVENTS.add("plan observed"); // Host observation does not move the task or replace completion intent.
|
|
214
263
|
QUIET_EVENTS.add("planner changed"); // Planning authority does not move the execution owner or revoke its handoff.
|
|
264
|
+
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
265
|
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
266
|
/** The records of the `done (checking)` entry a passed check now completes: what the owner delivered then (#509a). */
|
|
217
267
|
const latestPendingDeliverables = (task: Task): DeliverableRecord[] | undefined => {
|
|
@@ -312,11 +362,11 @@ const planText = (plan: TaskPlan = {}) => ALL_PLAN_KEYS.filter((k) => plan[k]?.l
|
|
|
312
362
|
/** What the PII screen reads of a task (#198): the text the patterns are checked against. */
|
|
313
363
|
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
364
|
/** Re-screens of one task whose verdict was unknown, per hub run (#198). */
|
|
315
|
-
const RESCREEN_MAX = 10;
|
|
365
|
+
export const RESCREEN_MAX = 10;
|
|
316
366
|
/** #758: the planner "board changed" notice. Its body starts with this, so an unsettled copy is found (and replaced) by the prefix. */
|
|
317
367
|
const GROOM_NOTICE_PREFIX = "The board changed";
|
|
318
368
|
/** #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;
|
|
369
|
+
export const GROOM_FYI_WINDOW_MS = 10 * 60_000;
|
|
320
370
|
|
|
321
371
|
/**
|
|
322
372
|
* The task flow. Adapters and tools never touch the board: every change comes through here, where assignment,
|
|
@@ -424,15 +474,16 @@ export class Tasks {
|
|
|
424
474
|
}
|
|
425
475
|
|
|
426
476
|
/** `signals.pii_screen = "local"` where the PII constraint applies (issue #198); on without a screener, every verdict is unknown. */
|
|
427
|
-
|
|
477
|
+
screenOn = (): boolean => {
|
|
428
478
|
const r = this.d.routing();
|
|
429
479
|
return r.signals.pii_screen === "local" && r.constraints.pii === "local_only";
|
|
430
480
|
};
|
|
431
481
|
|
|
432
482
|
/** One bounded screen call. A screener that fails is no verdict, handled as PII (fail closed). */
|
|
433
|
-
private requestCheck(by: PeerId): () => void {
|
|
483
|
+
private requestCheck(by: PeerId, fence?: () => string | undefined): () => void {
|
|
484
|
+
requireLocalTaskPeer(by);
|
|
434
485
|
const authority = this.d.requestAuthority?.(by);
|
|
435
|
-
const check = () => { const reason = authority?.(); if (reason) throw new PreEffectToolRefusal(reason); };
|
|
486
|
+
const check = () => { const reason = authority?.() ?? fence?.(); if (reason) throw new PreEffectToolRefusal(reason); };
|
|
436
487
|
check(); return check;
|
|
437
488
|
}
|
|
438
489
|
|
|
@@ -551,6 +602,7 @@ export class Tasks {
|
|
|
551
602
|
|
|
552
603
|
/** Captured at native turn start, not supplied by a model. */
|
|
553
604
|
ownerTurnTasks(peer: PeerId): { id: number; acceptance: number }[] {
|
|
605
|
+
requireLocalTaskPeer(peer);
|
|
554
606
|
return this.d.board.list("in_progress").flatMap(task => {
|
|
555
607
|
const accepted = task.owner === peer && ["implement", "bulk_edit"].includes(task.class) ? this.ownerAcceptance(task) : undefined;
|
|
556
608
|
return accepted ? [{ id: task.id, acceptance: accepted.index }] : [];
|
|
@@ -558,6 +610,7 @@ export class Tasks {
|
|
|
558
610
|
}
|
|
559
611
|
|
|
560
612
|
recordOwnerTurn(peer: PeerId, bindings: readonly { id: number; acceptance: number }[], changed: boolean | undefined, effect: boolean): void {
|
|
613
|
+
requireLocalTaskPeer(peer);
|
|
561
614
|
this.noWriteTurns.retain(this.d.board.list("in_progress").filter(task => ["implement", "bulk_edit"].includes(task.class)).map(task => task.id));
|
|
562
615
|
const current = new Map(this.ownerTurnTasks(peer).map(binding => [binding.id, binding.acceptance]));
|
|
563
616
|
for (const binding of bindings) {
|
|
@@ -662,6 +715,101 @@ export class Tasks {
|
|
|
662
715
|
}
|
|
663
716
|
return new Set(rows.map(row => find(row.id))).size;
|
|
664
717
|
}
|
|
718
|
+
/**
|
|
719
|
+
* #514b: the person's view of the recurring-criterion candidates (`ahub learn list`). The person only: a conductor or an agent gets a refusal, never a list. A card that does not parse as the
|
|
720
|
+
* hub's own is not listed. Ids, counts and the key parts only: the card's text is never echoed, and a PII row is never reached because the cards are already screened when filed.
|
|
721
|
+
*/
|
|
722
|
+
learnCandidates(by: PeerId, opts: { all?: boolean } = {}): Record<string, unknown>[] {
|
|
723
|
+
if (by !== USER) throw new PreEffectToolRefusal("ahub learn is a person's command");
|
|
724
|
+
const rows: Record<string, unknown>[] = [];
|
|
725
|
+
for (const card of this.d.board.list()) {
|
|
726
|
+
const parsed = parseCandidateCard(card);
|
|
727
|
+
if ("refused" in parsed) continue;
|
|
728
|
+
const open = card.state === "backlog" || card.state === "proposed";
|
|
729
|
+
const closed = card.history.findLast(entry => entry.event === "cancelled");
|
|
730
|
+
const verdict = learnVerdict(card) ?? (closed ? "closed" : undefined);
|
|
731
|
+
if (!open && !opts.all) continue;
|
|
732
|
+
if (!open && !closed) continue;
|
|
733
|
+
rows.push({ id: card.id, state: card.state, ...parsed, status: open ? "open" : verdict, tasks: this.learnEvidence(parsed).tasks, line: overridesDiff({ ...parsed, tasks: this.learnEvidence(parsed).tasks }).profile_overrides.review_checklist[0] });
|
|
734
|
+
}
|
|
735
|
+
return rows;
|
|
736
|
+
}
|
|
737
|
+
/** The distinct tasks that name this criterion unmet on this profile and class in the window, from the board as it is now: the count the written line states is recomputed, never read from the card. */
|
|
738
|
+
private learnEvidence(card: { profile: string; class: string; criterion: string }): { tasks: number[] } {
|
|
739
|
+
const eligible = this.d.board.list().filter(row => this.candidateTextAllowed(`${row.profile ?? ""} ${row.profileClass ?? ""} ${row.class}`, row));
|
|
740
|
+
const group = groupUnmetCriteria(eligible, { windowMs: LEARNING_DEFAULTS.windowMs, now: Date.now() }).find(g => g.profile === card.profile && g.class === card.class && g.criterion === card.criterion);
|
|
741
|
+
return { tasks: group?.tasks ?? [] };
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* #514b: what `ahub learn accept` would write for card `id`, or why not. Person only. It reads ONLY the card's key line (`parseCandidateCard`), requires the card to be open, and builds the
|
|
745
|
+
* line from the board's own evidence with `overridesDiff`: nothing of the card's text is parsed as a value. The daemon adds the line to the stored checklist and writes it through the settings path.
|
|
746
|
+
*/
|
|
747
|
+
learnAcceptPlan(by: PeerId, id: unknown): { id: number; profile: string; class: string; criterion: string; line: string; tasks: number } {
|
|
748
|
+
if (by !== USER) throw new PreEffectToolRefusal("ahub learn accept is a person's command");
|
|
749
|
+
const card = this.learnCard(id);
|
|
750
|
+
const evidence = this.learnEvidence(card);
|
|
751
|
+
if (!evidence.tasks.length) throw new PreEffectToolRefusal(`no task in the window still names ${card.criterion} unmet on ${card.profile}/${card.class}: nothing to accept; reject the card to close it`);
|
|
752
|
+
const line = overridesDiff({ ...card, tasks: evidence.tasks }).profile_overrides.review_checklist[0]!;
|
|
753
|
+
return { id: card.id, profile: card.profile, class: card.class, criterion: card.criterion, line, tasks: evidence.tasks.length };
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* #514b: whether `ahub learn accept` already decided this card. Person only. A card the person accepted answers `"accepted"`, so a repeat accept is a no-write success (the plan's idempotency);
|
|
757
|
+
* a rejected card, a card closed some other way and an open one answer undefined and take the normal path, which refuses the closed ones.
|
|
758
|
+
*/
|
|
759
|
+
learnAccepted(by: PeerId, id: unknown): { id: number; profile: string; class: string; criterion: string } | undefined {
|
|
760
|
+
if (by !== USER) throw new PreEffectToolRefusal("ahub learn accept is a person's command");
|
|
761
|
+
if (typeof id !== "number" || !Number.isSafeInteger(id) || id <= 0) return undefined;
|
|
762
|
+
const task = this.d.board.get(id);
|
|
763
|
+
if (!task || learnVerdict(task) !== "accepted") return undefined;
|
|
764
|
+
const parsed = parseCandidateCard(task);
|
|
765
|
+
return "refused" in parsed ? undefined : { id, ...parsed };
|
|
766
|
+
}
|
|
767
|
+
/** The card, which must be an open candidate the hub filed. */
|
|
768
|
+
private learnCard(id: unknown): { id: number; profile: string; class: string; criterion: string } {
|
|
769
|
+
if (typeof id !== "number" || !Number.isSafeInteger(id) || id <= 0) throw new PreEffectToolRefusal("id must be a positive task number");
|
|
770
|
+
const task = this.d.board.get(id);
|
|
771
|
+
if (!task) throw new PreEffectToolRefusal(`no task #${id}`);
|
|
772
|
+
const parsed = parseCandidateCard(task);
|
|
773
|
+
if ("refused" in parsed) throw new PreEffectToolRefusal(`task #${id}: ${parsed.refused}`);
|
|
774
|
+
if (task.state !== "backlog" && task.state !== "proposed") throw new PreEffectToolRefusal(`task #${id} is already ${task.state === "cancelled" ? "closed" : task.state}`);
|
|
775
|
+
return { id, ...parsed };
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* #514b: closes a candidate card after the person's verdict, through `cancel` (never a direct board write): the row stays, the verdict is in its history, and because the dedupe key is the card's
|
|
779
|
+
* detail the same criterion is never filed again. `accepted` is recorded only after the write succeeded (the daemon calls this last); a repeat of either verdict is refused by `learnCard`.
|
|
780
|
+
*/
|
|
781
|
+
async learnClose(by: PeerId, id: unknown, verdict: "accepted" | "rejected"): Promise<Task> {
|
|
782
|
+
if (by !== USER) throw new PreEffectToolRefusal("ahub learn is a person's command");
|
|
783
|
+
const card = this.learnCard(id);
|
|
784
|
+
return this.cancel(card.id, USER, { reason: verdict === "accepted" ? `${LEARN_ACCEPTED} by the person: its review checklist line was written to config.local.json` : `${LEARN_REJECTED} by the person` });
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* #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
|
|
788
|
+
* `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`
|
|
789
|
+
* 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.
|
|
790
|
+
*/
|
|
791
|
+
private proposeCriterionCandidates(task: Task, by: PeerId, withheld: boolean): void {
|
|
792
|
+
if (withheld || this.isPii(task) || this.d.privateTurn?.(by) || !task.history.at(-1)?.unmetCriteria?.length) return;
|
|
793
|
+
const rows = this.d.board.list();
|
|
794
|
+
const keys = new Set(rows.filter(row => row.class === "plan" && row.history[0]?.by === HUB)
|
|
795
|
+
.flatMap(row => row.detail?.split("\n").filter(line => line.startsWith("Criterion candidate key: ")) ?? []));
|
|
796
|
+
// Only the rows that may count: candidateTextAllowed refuses a PII row and one whose text the screen refuses, so those are evidence of nothing.
|
|
797
|
+
const eligible = rows.filter(row => this.candidateTextAllowed(`${row.profile ?? ""} ${row.profileClass ?? ""} ${row.class}`, row));
|
|
798
|
+
const named = new Set(task.history.at(-1)!.unmetCriteria);
|
|
799
|
+
const created: number[] = [];
|
|
800
|
+
// No await separates the durable dedupe read from creation; concurrent verdicts see the first card.
|
|
801
|
+
const groups = groupUnmetCriteria(eligible, { windowMs: LEARNING_DEFAULTS.windowMs, now: Date.now() });
|
|
802
|
+
const unitsOf = (group: CriterionGroup) => this.workUnits(eligible.filter(row => group.tasks.includes(row.id)));
|
|
803
|
+
for (const group of selectGroups(groups, { named, threshold: LEARNING_DEFAULTS.threshold, max: LEARNING_DEFAULTS.maxGroups, has: g => keys.has(criterionKey(g)), units: unitsOf })) {
|
|
804
|
+
const card = candidateCard(group, Math.round(LEARNING_DEFAULTS.windowMs / 86_400_000), unitsOf(group));
|
|
805
|
+
const draft = { title: card.title, detail: card.detail, refs: { paths: ["docs/profiles.md"] } };
|
|
806
|
+
const signals = this.signalsFor(draft);
|
|
807
|
+
if (this.isPii({ signals }) || !this.candidateTextAllowed(`${card.title}\n${card.detail}`)) continue;
|
|
808
|
+
const proposal = this.d.board.propose(HUB, { ...draft, class: "plan", backlog: true, signals });
|
|
809
|
+
this.incomingBacklog.add(proposal.id); keys.add(criterionKey(group)); created.push(proposal.id);
|
|
810
|
+
}
|
|
811
|
+
for (const id of created) this.releaseNotice(`task #${id}: recurring unmet criterion candidate proposed in the backlog`);
|
|
812
|
+
}
|
|
665
813
|
private proposeRecurringFindings(task: Task, by: PeerId, items: readonly ReviewFinding[], withheld: boolean): void {
|
|
666
814
|
if (withheld || this.isPii(task) || this.d.privateTurn?.(by) || !items.length || !this.findingTextAllowed(task, items)) return;
|
|
667
815
|
const rows = this.d.board.list();
|
|
@@ -698,7 +846,14 @@ export class Tasks {
|
|
|
698
846
|
|
|
699
847
|
publicView(task: Task, fullHistory = false): Record<string, unknown> {
|
|
700
848
|
const { title, detail, history, external, cancelled, codeSymbols, ...taskRest } = task;
|
|
701
|
-
const
|
|
849
|
+
const plannerBinding = history.findLast(h => h.planner === task.planner && h.plannerReason !== undefined);
|
|
850
|
+
const ownerBinding = history.findLast(h => h.owner === task.owner && ["assigned", "reassigned", "escalated", "backlog pulled"].includes(h.event));
|
|
851
|
+
const reviewerBinding = history.findLast(h => ["assigned", "reassigned", "escalated", "done", "reviewer changed", "reviewer unavailable"].includes(h.event));
|
|
852
|
+
const rest = { ...taskRest,
|
|
853
|
+
...(plannerBinding?.plannerReason && ["rotation", "named", "kept", "none"].includes(plannerBinding.plannerReason) ? { plannerReason: plannerBinding.plannerReason } : {}),
|
|
854
|
+
...(ownerBinding?.reason && TASK_MOVE_REASONS.includes(ownerBinding.reason) ? { ownerReason: ownerBinding.reason === "manual" && ownerBinding.event === "reassigned" && ownerBinding.by !== HUB ? "named" : ownerBinding.reason } : {}),
|
|
855
|
+
...(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" } : {}),
|
|
856
|
+
...(history.some(h => h.by === USER && h.planDeparture) ? { planDepartureAccepted: true } : {}) };
|
|
702
857
|
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
858
|
if (rest.sdlc?.redFirst && !this.publicMetadataText(JSON.stringify(rest.sdlc.redFirst))) {
|
|
704
859
|
const { redFirst: _withheld, ...receipt } = rest.sdlc; rest.sdlc = receipt;
|
|
@@ -723,6 +878,7 @@ export class Tasks {
|
|
|
723
878
|
...(h.unmetCriteria?.length ? { unmetCriteria: h.unmetCriteria } : {}),
|
|
724
879
|
...(h.deliverables?.length ? { deliverables: publicDeliverables(h.deliverables, this.nameable) } : {}),
|
|
725
880
|
...(h.reviewBlocked ? { reviewBlocked: true } : {}),
|
|
881
|
+
...(h.queueReviewHeld ? { queueReviewHeld: true } : {}),
|
|
726
882
|
...(h.reviewerFailure ? { reviewerFailure: { peer: screenHistoryText(h.reviewerFailure.peer, "reviewer"), cause: h.reviewerFailure.cause, ...(h.reviewerFailure.head ? { head: screenHistoryText(h.reviewerFailure.head, "review head") } : {}) } } : {}),
|
|
727
883
|
...(h.planning ? { planning: { revision: h.planning.revision, fields: h.planning.fields, ...(h.planning.split ? { children: h.planning.split.children.map(child => child.id) } : {}) } } : {}),
|
|
728
884
|
...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
|
|
@@ -757,14 +913,15 @@ export class Tasks {
|
|
|
757
913
|
/** Decayed failure weights of the peers demoted for a class: failures count half after a day (issue #36). */
|
|
758
914
|
demoted(cls: TaskClass, now = Date.now()): Record<PeerId, number> {
|
|
759
915
|
const sums = new Map<PeerId, { bad: number; good: number }>();
|
|
916
|
+
const halfLifeH = this.d.demotionHalfLifeH?.(), halfLifeMs = halfLifeH === undefined ? DEMOTION_HALF_LIFE_MS : halfLifeH * 3_600_000, demoteAt = this.d.demoteAt?.() ?? DEMOTE_AT;
|
|
760
917
|
for (const o of this.d.board.outcomes(cls, now - OUTCOMES_KEPT_MS)) {
|
|
761
|
-
const w = 0.5 ** ((now - o.at) /
|
|
918
|
+
const w = 0.5 ** ((now - o.at) / halfLifeMs);
|
|
762
919
|
const s = sums.get(o.peer) ?? { bad: 0, good: 0 };
|
|
763
920
|
if (o.ok) s.good += w;
|
|
764
921
|
else s.bad += w;
|
|
765
922
|
sums.set(o.peer, s);
|
|
766
923
|
}
|
|
767
|
-
return Object.fromEntries([...sums].filter(([, s]) => s.bad >=
|
|
924
|
+
return Object.fromEntries([...sums].filter(([, s]) => s.bad >= demoteAt && s.bad > s.good).map(([p, s]) => [p, s.bad]));
|
|
768
925
|
}
|
|
769
926
|
|
|
770
927
|
/** How each reviewer's reviews of each implementer's work in a class held up (issue #35). */
|
|
@@ -801,20 +958,57 @@ export class Tasks {
|
|
|
801
958
|
}
|
|
802
959
|
|
|
803
960
|
/** Workload is board/queue data only: wip() never calls health() or weights(). */
|
|
804
|
-
private instanceRouting(): InstanceRoutingOptions {
|
|
961
|
+
private instanceRouting(ownerIsolation?: Readonly<Record<string, boolean>>): InstanceRoutingOptions {
|
|
805
962
|
const instances = this.d.instances?.();
|
|
806
963
|
if (!instances || !Object.keys(instances).length) return { adapterCatalog: this.d.adapterCatalog };
|
|
807
964
|
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)])) };
|
|
965
|
+
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)])) };
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
private queuePolicy(): QueueBackpressurePolicy | undefined {
|
|
969
|
+
const policy = queueBackpressurePolicy(this.d.queueBackpressure?.());
|
|
970
|
+
return policy.max_queued > 0 || policy.max_oldest_s > 0 ? policy : undefined;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/** Admission is pending metadata only; an unavailable read never invents an empty/fresh queue. */
|
|
974
|
+
private queueRouting(peers: readonly PeerId[], policy = this.queuePolicy()): Pick<InstanceRoutingOptions, "queues" | "queueBackpressure"> & { now?: number } {
|
|
975
|
+
if (!policy) return {};
|
|
976
|
+
const queues = Object.fromEntries([...new Set(peers)].map(peer => {
|
|
977
|
+
let value: QueuePressure;
|
|
978
|
+
try { value = this.d.bus.assignmentQueue(peer); } catch { value = { queued: NaN }; }
|
|
979
|
+
return [peer, value];
|
|
980
|
+
}));
|
|
981
|
+
return { queueBackpressure: policy, queues, now: this.d.now?.() ?? Date.now() };
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
private contextRouting(): Pick<InstanceRoutingOptions, "contextAdmission"> & { now?: number } {
|
|
985
|
+
const admission = this.d.contextAdmission?.();
|
|
986
|
+
if (!admission || !Number.isFinite(admission.policy.max_used) || admission.policy.max_used <= 0 || admission.policy.max_used > 1) return {};
|
|
987
|
+
return { contextAdmission: admission, now: this.d.now?.() ?? Date.now() };
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/** Public ids/counts/reasons only; no board history, pending envelope body or task text is read. */
|
|
991
|
+
queuePressureStatus(peers: readonly string[]): QueueBackpressureStatus | undefined {
|
|
992
|
+
const policy = this.queuePolicy();
|
|
993
|
+
if (!policy) return undefined;
|
|
994
|
+
const all = [...new Set(peers)].filter(peer => /^[a-z][a-z0-9-]{0,31}$/.test(peer));
|
|
995
|
+
const bounded = all.slice(0, 4096), input = this.queueRouting(bounded, policy), now = input.now!;
|
|
996
|
+
const rows = Object.fromEntries(bounded.map(peer => {
|
|
997
|
+
const value = peerEntry(input.queues, peer), refusal = queueBackpressure(peer, policy, value, now);
|
|
998
|
+
const queued = value && Number.isSafeInteger(value.queued) && value.queued >= 0 ? value.queued : null;
|
|
999
|
+
const at = queued !== null && queued > 0 && typeof value?.oldestQueuedAt === "number" && Number.isSafeInteger(value.oldestQueuedAt) && value.oldestQueuedAt >= 0 && value.oldestQueuedAt <= now ? value.oldestQueuedAt : null;
|
|
1000
|
+
return [peer, { queued, oldestQueuedAt: at, oldestAgeMs: at === null ? null : now - at, blocked: refusal !== undefined, ...(refusal ? { code: refusal.code, reason: refusal.reason } : {}) }];
|
|
1001
|
+
}));
|
|
1002
|
+
return { policy, peers: rows, ...(all.length > bounded.length ? { truncated: true as const } : {}) };
|
|
809
1003
|
}
|
|
810
1004
|
|
|
811
1005
|
/** Peer health and roles the daemon feeds routing (issues #89, #90, #92): failing peers are skipped, held queues explained. */
|
|
812
|
-
private health() {
|
|
1006
|
+
private health(ownerIsolation?: Readonly<Record<string, boolean>>) {
|
|
813
1007
|
const failing = this.d.failing?.();
|
|
814
1008
|
const held = this.d.held?.();
|
|
815
|
-
const workload = this.instanceRouting();
|
|
1009
|
+
const workload = this.instanceRouting(ownerIsolation);
|
|
816
1010
|
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 } : {}) };
|
|
1011
|
+
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
1012
|
}
|
|
819
1013
|
|
|
820
1014
|
private reviewHead(task: Task): string | undefined { return task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead; }
|
|
@@ -834,21 +1028,30 @@ export class Tasks {
|
|
|
834
1028
|
private poolReview(task: Task, refs?: TaskRefs): Assignment | undefined {
|
|
835
1029
|
if (this.manuallyNamedReviewer(task) || this.isPii(task) || task.class === "review") return undefined;
|
|
836
1030
|
const draft = { ...task, refs: { ...task.refs, ...cleanRefs(refs) } };
|
|
837
|
-
const
|
|
838
|
-
|
|
1031
|
+
const weights = this.weights(task.class, draft);
|
|
1032
|
+
const selection = assign(draft, this.states(), this.d.routing(), { ...weights, candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}) });
|
|
1033
|
+
const heldSelection = task.reviewer === USER && task.history[this.reviewRequestRevision(task) - 1]?.queueReviewHeld === true;
|
|
1034
|
+
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;
|
|
1035
|
+
const queueRefused = previous?.refusals?.some(row => row.role === "reviewer" && row.peer === task.reviewer && isQueuePressureCode(row.code));
|
|
1036
|
+
const newlyBlocked = selection.reviewBlocked && selection.refusals?.some(row => row.role === "reviewer" && isQueuePressureCode(row.code));
|
|
1037
|
+
return selection.reviewSelection || queueRefused || newlyBlocked || heldSelection ? selection : undefined;
|
|
839
1038
|
}
|
|
840
1039
|
|
|
841
|
-
private reviewMetadata(assignment?: Assignment): ReviewerHistory {
|
|
1040
|
+
private reviewMetadata(assignment?: Assignment, task?: Task): ReviewerHistory {
|
|
842
1041
|
const choice = assignment?.reviewSelection;
|
|
843
1042
|
const family = choice && assignment?.plannerFallback && assignment.reviewer ? (this.d.adapterCatalog?.familyOf ?? familyOf)(assignment.reviewer, this.d.instances?.()) : choice?.family;
|
|
844
1043
|
const reason = choice && assignment?.plannerFallback
|
|
845
1044
|
? assignment.sameFamilyFallback ? "same-family fallback" as const : "unavailable skip" as const
|
|
846
1045
|
: choice?.reason;
|
|
847
|
-
|
|
1046
|
+
const queueReviewHeld = task !== undefined && !this.isPii(task) && assignment?.reviewer === USER && assignment.reviewBlocked === true
|
|
1047
|
+
&& assignment.refusals?.some(row => row.role === "reviewer" && isQueuePressureCode(row.code));
|
|
1048
|
+
const queueReviewBinding = task ? this.queueReviewBinding(task) : undefined;
|
|
1049
|
+
return { ...(family ? { reviewerFamily: family } : {}), ...(reason ? { reviewerReason: reason } : {}), ...(assignment?.reviewBlocked ? { reviewBlocked: true } : {}), ...(queueReviewHeld ? { queueReviewHeld: true as const, ...(queueReviewBinding !== undefined ? { queueReviewBinding } : {}) } : {}) };
|
|
848
1050
|
}
|
|
849
1051
|
|
|
850
1052
|
/** Capability admission precedes explicit assignments/reservations, independent of readiness or transient peer health. */
|
|
851
1053
|
private requireCapabilities(task: Parameters<typeof assign>[0], peer: PeerId, role: Exclude<TaskAssignmentRole, "planner"> = "owner", preEffect = true): void {
|
|
1054
|
+
requireLocalTaskPeer(peer);
|
|
852
1055
|
const instances = this.d.instances?.(), adapter = (this.d.adapterCatalog?.definitionOf ?? definitionOf)(peer, instances);
|
|
853
1056
|
const declaredExtra = !!adapter && adapter.kind !== peer;
|
|
854
1057
|
if ((!this.d.capabilities || this.isPii(task)) && !declaredExtra) return;
|
|
@@ -865,13 +1068,14 @@ export class Tasks {
|
|
|
865
1068
|
* Selection stays in assign(); task bindings only keep that selection while the holder remains eligible.
|
|
866
1069
|
*/
|
|
867
1070
|
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();
|
|
1071
|
+
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
1072
|
}
|
|
870
1073
|
|
|
871
|
-
private weights(cls: TaskClass, task?: Task) {
|
|
1074
|
+
private weights(cls: TaskClass, task?: Task, projectedHealth?: ReturnType<Tasks["health"]>) {
|
|
872
1075
|
const now = Date.now();
|
|
873
1076
|
const quota = this.d.quota?.();
|
|
874
|
-
|
|
1077
|
+
const health = projectedHealth ?? this.health(), clock = health.now ?? now;
|
|
1078
|
+
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
1079
|
}
|
|
876
1080
|
|
|
877
1081
|
/**
|
|
@@ -929,6 +1133,8 @@ export class Tasks {
|
|
|
929
1133
|
peers,
|
|
930
1134
|
observations: Object.fromEntries(peers.map((p) => [p, this.splitObservations(task.class, p, task.id)])),
|
|
931
1135
|
units: [unit, unit],
|
|
1136
|
+
...(this.d.splitMinObservations ? { minObservations: this.d.splitMinObservations() } : {}),
|
|
1137
|
+
...(this.d.splitMaxFailureShare ? { maxFailureShare: this.d.splitMaxFailureShare() } : {}),
|
|
932
1138
|
profiles: Object.fromEntries(peers.map((p) => [p, this.d.splitProfile?.(p)])),
|
|
933
1139
|
backlog: Object.fromEntries(peers.map((p) => [p, open.filter((t) => t.owner === p).length])),
|
|
934
1140
|
// Busy is taking the task in question only in the turn that task started (an owner goes busy as its task is
|
|
@@ -946,11 +1152,12 @@ export class Tasks {
|
|
|
946
1152
|
private contradict(failed: Task): void {
|
|
947
1153
|
if (this.isPii(failed)) return; // its places are left out everywhere else too
|
|
948
1154
|
const now = Date.now();
|
|
1155
|
+
const contradictionDays = this.d.contradictionWindowDays?.(), contradictionWindowMs = contradictionDays === undefined ? CONTRADICTION_WINDOW_MS : contradictionDays * 86_400_000;
|
|
949
1156
|
const mine = this.places(failed);
|
|
950
1157
|
for (const t of this.d.board.list("approved")) {
|
|
951
1158
|
if (t.id === failed.id || !t.owner || this.isPii(t)) continue;
|
|
952
1159
|
const approval = [...t.history].reverse().find((h) => h.event === "approved");
|
|
953
|
-
if (!approval || now - approval.at >
|
|
1160
|
+
if (!approval || now - approval.at > contradictionWindowMs || approval.by === USER) continue;
|
|
954
1161
|
const theirs = this.places(t);
|
|
955
1162
|
// Blame needs the same file or symbol: a directory or `.` would contradict every approval under it. Older rows
|
|
956
1163
|
// were stored as written, so both sides are compared in one spelling.
|
|
@@ -968,12 +1175,13 @@ export class Tasks {
|
|
|
968
1175
|
|
|
969
1176
|
/** A removed/off-roster binding falls back to legacy holders; a busy, paused or offline holder keeps authority. */
|
|
970
1177
|
taskPlanner(task: Pick<Task, "planner">): PeerId[] {
|
|
971
|
-
const holders = Object.keys(this.d.roles ?? {}).filter(peer => this.d.roles?.[peer]?.includes("planner")).sort();
|
|
1178
|
+
const holders = Object.keys(this.d.roles ?? {}).filter(peer => !isRemotePeerId(peer) && this.d.roles?.[peer]?.includes("planner")).sort();
|
|
972
1179
|
return task.planner && holders.includes(task.planner) && this.d.bus.isRostered(task.planner) ? [task.planner] : holders;
|
|
973
1180
|
}
|
|
974
1181
|
|
|
975
1182
|
/** Current planner rulings amend review context, never person-only plan-departure acceptance. */
|
|
976
1183
|
plannerRulings(task: Task, recipient: PeerId): { historyIndex: number; by: PeerId; at: number; note: string }[] {
|
|
1184
|
+
requireLocalTaskPeer(recipient);
|
|
977
1185
|
if (this.isPii(task)) return [];
|
|
978
1186
|
const boundary = task.history.findLastIndex(entry => entry.event === "accepted" || entry.event === "claimed");
|
|
979
1187
|
if (boundary < 0) return [];
|
|
@@ -991,13 +1199,15 @@ export class Tasks {
|
|
|
991
1199
|
}
|
|
992
1200
|
|
|
993
1201
|
private planningAuthority(by: PeerId, task?: Pick<Task, "planner">): void {
|
|
1202
|
+
requireLocalTaskPeer(by);
|
|
994
1203
|
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
1204
|
}
|
|
996
1205
|
|
|
997
1206
|
/** #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"); }
|
|
1207
|
+
isConfiguredPlanner(peer: PeerId): boolean { return !isRemotePeerId(peer) && peer !== USER && peer !== HUB && !!this.d.roles?.[peer]?.includes("planner"); }
|
|
999
1208
|
|
|
1000
1209
|
private plannerWriteAuthority(by: PeerId, task?: Pick<Task, "planner">): void {
|
|
1210
|
+
requireLocalTaskPeer(by);
|
|
1001
1211
|
if (by !== USER && this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("planner writes are unavailable during a private turn");
|
|
1002
1212
|
this.planningAuthority(by, task);
|
|
1003
1213
|
}
|
|
@@ -1063,6 +1273,7 @@ export class Tasks {
|
|
|
1063
1273
|
|
|
1064
1274
|
/** Host-observed task branch/root binding (#845), never a tool-call branch claim or an execution grant. */
|
|
1065
1275
|
bindShellBranch(peer: PeerId, id: number, binding: TaskShellBinding & { worktree: string }, current: () => boolean): boolean {
|
|
1276
|
+
requireLocalTaskPeer(peer);
|
|
1066
1277
|
try {
|
|
1067
1278
|
if (!Number.isSafeInteger(id) || id < 1 || !taskShellBindingValid(binding) || !this.publicMetadataText(binding.branch)
|
|
1068
1279
|
|| !current() || this.d.privateTurn?.(peer)) return false;
|
|
@@ -1143,7 +1354,7 @@ export class Tasks {
|
|
|
1143
1354
|
/** 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
1355
|
private topReservedReady(peer: PeerId, planner?: PeerId, pullable = false): Task | undefined {
|
|
1145
1356
|
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)
|
|
1357
|
+
&& (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
1358
|
&& (card.release == null || ["planning", "active"].includes(this.d.board.release(card.release)?.state ?? ""))).sort(this.readyOrder)[0];
|
|
1148
1359
|
}
|
|
1149
1360
|
|
|
@@ -1207,6 +1418,7 @@ export class Tasks {
|
|
|
1207
1418
|
plannerRefusal(task: Task): string {
|
|
1208
1419
|
const parts: string[] = [];
|
|
1209
1420
|
const excluded = [...new Set(this.excluded(task))];
|
|
1421
|
+
const pressure = this.queueRouting(Object.keys(this.states()));
|
|
1210
1422
|
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
1423
|
for (const peer of Object.keys(this.states())) {
|
|
1212
1424
|
if (excluded.includes(peer)) continue;
|
|
@@ -1214,14 +1426,16 @@ export class Tasks {
|
|
|
1214
1426
|
if (wip >= limit) parts.push(`${peer} WIP full (${wip}/${limit}); no eligible planner WIP capacity`);
|
|
1215
1427
|
else if (queue.heldBy) parts.push(`${peer} has a held queue (a delivery is held; resolve it with ahub queue list)`);
|
|
1216
1428
|
else if (queue.needsReview > 0) parts.push(`${peer} has ${queue.needsReview} delivery(ies) in needs_review`);
|
|
1429
|
+
const refusal = queueBackpressure(peer, pressure.queueBackpressure, peerEntry(pressure.queues, peer), pressure.now ?? NaN);
|
|
1430
|
+
if (refusal) parts.push(`${peer} ${refusal.reason}`);
|
|
1217
1431
|
}
|
|
1218
1432
|
return parts.length ? `no peer can take the planner pull: ${parts.join("; ")}` : "no peer has eligible planner WIP capacity";
|
|
1219
1433
|
}
|
|
1220
|
-
private plannerAssignment(task: Task, reservedOnly = false, routing: Routing = this.d.routing()): Assignment {
|
|
1434
|
+
private plannerAssignment(task: Task, reservedOnly = false, routing: Routing = this.d.routing(), queueOverride?: "owner", projectedHealth?: ReturnType<Tasks["health"]>): Assignment {
|
|
1221
1435
|
const states = this.states();
|
|
1222
1436
|
const full = this.plannerFull(task);
|
|
1223
1437
|
// 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) });
|
|
1438
|
+
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
1439
|
}
|
|
1226
1440
|
|
|
1227
1441
|
async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }, opts: { onBehalfOf?: PeerId; guard?: () => void } = {}): Promise<Task> {
|
|
@@ -1229,13 +1443,18 @@ export class Tasks {
|
|
|
1229
1443
|
// #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
1444
|
if (opts.onBehalfOf !== undefined && by !== HUB) throw new PreEffectToolRefusal("only the hub pulls on a planner's behalf");
|
|
1231
1445
|
const planner = opts.onBehalfOf ?? by;
|
|
1446
|
+
let queueAssignment: { current: () => void } | undefined;
|
|
1232
1447
|
// `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
1448
|
// 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?.(); };
|
|
1449
|
+
const authorized = () => { this.planningAuthority(planner); opts.guard?.(); queueAssignment?.current(); };
|
|
1235
1450
|
authorized();
|
|
1236
1451
|
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");
|
|
1452
|
+
queueAssignment = by !== HUB && opts.onBehalfOf === undefined && this.d.board.get(input.id)?.reserved
|
|
1453
|
+
? this.explicitQueueAssignment(by, "owner", true) : undefined;
|
|
1454
|
+
queueAssignment?.current();
|
|
1237
1455
|
const validate = () => {
|
|
1238
1456
|
const task = this.d.board.get(input.id);
|
|
1457
|
+
if (task?.draft) throw new PreEffectToolRefusal(`task #${task.id} is an intake draft; the person approves it first`);
|
|
1239
1458
|
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
1459
|
this.planningAuthority(planner, task);
|
|
1241
1460
|
const release = task.release == null ? undefined : this.d.board.release(task.release);
|
|
@@ -1253,7 +1472,7 @@ export class Tasks {
|
|
|
1253
1472
|
})) throw new PreEffectToolRefusal("a person's P0 backlog order is retained");
|
|
1254
1473
|
}
|
|
1255
1474
|
if (task.reserved && this.excluded(task).includes(task.reserved)) throw new PreEffectToolRefusal(this.plannerRefusal(task));
|
|
1256
|
-
const assignment = this.plannerAssignment(task, !!task.reserved);
|
|
1475
|
+
const assignment = this.plannerAssignment(task, !!task.reserved, this.d.routing(), queueAssignment ? "owner" : undefined);
|
|
1257
1476
|
if (!assignment.owner) {
|
|
1258
1477
|
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
1478
|
throw new PreEffectToolRefusal(`${this.hasPersonReservation(task) ? "a person's reservation is retained; " : ""}${refusal}`);
|
|
@@ -1264,7 +1483,7 @@ export class Tasks {
|
|
|
1264
1483
|
authorized();
|
|
1265
1484
|
const current = validate();
|
|
1266
1485
|
if (current.task.reserved) {
|
|
1267
|
-
return this.assignOwner(current.task, by, { candidates: [current.owner], namedOwner: current.owner, planner: true, refuseNamed: "pre",
|
|
1486
|
+
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
1487
|
plannerPull: { revision: input.revision, note: input.reason.trim(), withheld, guard: () => {
|
|
1269
1488
|
authorized();
|
|
1270
1489
|
const checked = validate();
|
|
@@ -1286,7 +1505,7 @@ export class Tasks {
|
|
|
1286
1505
|
const pending = this.plannerBacklog.get(peer) ?? new Set<number>();
|
|
1287
1506
|
for (const id of ids) {
|
|
1288
1507
|
const task = this.d.board.get(id);
|
|
1289
|
-
if (task?.state === "backlog" && this.taskPlanner(task).includes(peer)) pending.add(id);
|
|
1508
|
+
if (task?.state === "backlog" && !task.draft && this.taskPlanner(task).includes(peer)) pending.add(id);
|
|
1290
1509
|
}
|
|
1291
1510
|
if (pending.size) this.plannerBacklog.set(peer, pending);
|
|
1292
1511
|
}
|
|
@@ -1327,11 +1546,13 @@ export class Tasks {
|
|
|
1327
1546
|
scan.revision = revision;
|
|
1328
1547
|
}
|
|
1329
1548
|
const now = this.d.now?.() ?? Date.now();
|
|
1549
|
+
const configuredFyiMin = this.d.groomFyiWindowMin?.();
|
|
1550
|
+
const fyiWindowMs = configuredFyiMin === undefined ? GROOM_FYI_WINDOW_MS : configuredFyiMin * 60_000;
|
|
1330
1551
|
for (const peer of planners) {
|
|
1331
1552
|
const told = scan.sent.get(peer), pending = scan.pending.get(peer);
|
|
1332
1553
|
const review = told === undefined || pending?.review === true;
|
|
1333
1554
|
if (!review && !pending?.other) continue;
|
|
1334
|
-
if (!review && now - told!.fyiAt <
|
|
1555
|
+
if (!review && now - told!.fyiAt < fyiWindowMs) { scan.pending.delete(peer); continue; }
|
|
1335
1556
|
this.releaseNotice(`could not notify planner ${peer} about grooming`, () => {
|
|
1336
1557
|
if (review) this.sendGroomNotice(peer, revision);
|
|
1337
1558
|
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 +1572,7 @@ export class Tasks {
|
|
|
1351
1572
|
|
|
1352
1573
|
/** One bounded ids-only batch per attached planner; failed or evicted copies stay pending. */
|
|
1353
1574
|
flushPlannerNotices(): number {
|
|
1354
|
-
const configured = Object.entries(this.d.roles ?? {}).filter(([, roles]) => roles.includes("planner")).map(([peer]) => peer);
|
|
1575
|
+
const configured = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => !isRemotePeerId(peer) && roles.includes("planner")).map(([peer]) => peer);
|
|
1355
1576
|
for (const peer of this.knownPlanners) if (!configured.includes(peer)) {
|
|
1356
1577
|
this.knownPlanners.delete(peer); this.plannerBacklog.delete(peer); this.plannerBlockedQueue.delete(peer);
|
|
1357
1578
|
this.groomScan.pending.delete(peer); this.groomScan.sent.delete(peer);
|
|
@@ -1542,7 +1763,7 @@ export class Tasks {
|
|
|
1542
1763
|
}
|
|
1543
1764
|
|
|
1544
1765
|
async importGitHub(by: PeerId, input: GitHubImportSelection, run?: SdlcGh): Promise<{ imported: number[]; existing: number[]; partial: boolean; complete: boolean; unconfirmed?: true }> {
|
|
1545
|
-
if (by
|
|
1766
|
+
if (!this.personOrConductor(by)) throw new PreEffectToolRefusal("GitHub import is a person console command");
|
|
1546
1767
|
const deadline = performance.now() + 20_000;
|
|
1547
1768
|
const page = await readGitHubImport(input, run ?? ((args, timeout, _cwd, bound) => sdlcGh(args, timeout, this.d.cwd, bound)));
|
|
1548
1769
|
const imported: number[] = [], existing: number[] = [];
|
|
@@ -1554,7 +1775,7 @@ export class Tasks {
|
|
|
1554
1775
|
if (prior) { existing.push(prior.id); continue; }
|
|
1555
1776
|
const classes = profileData(this.d.profileData ?? this.d.profile ?? "dev").classes;
|
|
1556
1777
|
const cls = Object.keys(classes).find(name => classes[name] === "implement") ?? Object.keys(classes)[0];
|
|
1557
|
-
const task = await this.propose(
|
|
1778
|
+
const task = await this.propose(by, { title: issue.title, detail: issue.detail, class: cls, backlog: true }, issue.source, () => { created = true; });
|
|
1558
1779
|
(created ? imported : existing).push(task.id);
|
|
1559
1780
|
if (performance.now() >= deadline) return { imported, existing, partial: true, complete: false };
|
|
1560
1781
|
} catch {
|
|
@@ -1570,8 +1791,10 @@ export class Tasks {
|
|
|
1570
1791
|
return { imported, existing, partial: page.partial, complete: !page.partial };
|
|
1571
1792
|
}
|
|
1572
1793
|
|
|
1573
|
-
async splitBacklog(by: PeerId, input: { id: number; revision: number; parts: unknown; reason: string }): Promise<{ parent: Task; children: Task[] }> {
|
|
1574
|
-
|
|
1794
|
+
async splitBacklog(by: PeerId, input: { id: number; revision: number; parts: unknown; reason: string }, opts: { intake?: true } = {}): Promise<{ parent: Task; children: Task[] }> {
|
|
1795
|
+
// #334: the hub writes an intake draft's own model split; no other write by the hub passes as a planner.
|
|
1796
|
+
const authority = (task?: Pick<Task, "planner">) => { if (!(opts.intake && by === HUB)) this.planningAuthority(by, task); };
|
|
1797
|
+
authority();
|
|
1575
1798
|
const { id, revision } = input;
|
|
1576
1799
|
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
1800
|
const reason = input.reason.trim();
|
|
@@ -1584,7 +1807,9 @@ export class Tasks {
|
|
|
1584
1807
|
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
1808
|
const release = parent.release == null ? undefined : this.d.board.release(parent.release);
|
|
1586
1809
|
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
|
-
|
|
1810
|
+
authority(parent);
|
|
1811
|
+
// #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.
|
|
1812
|
+
if (opts.intake && by === HUB && !parent.draft) throw new PreEffectToolRefusal("split changed, pinned or already started");
|
|
1588
1813
|
const profile = parent.profile ?? "dev";
|
|
1589
1814
|
const drafts = rows.map((part, index) => {
|
|
1590
1815
|
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 +1844,7 @@ export class Tasks {
|
|
|
1619
1844
|
let screened: Awaited<typeof attempt>;
|
|
1620
1845
|
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
1846
|
finally { if (deadline !== undefined) clearTimeout(deadline); }
|
|
1622
|
-
|
|
1847
|
+
authority(); // #455: a person may have moved the planner while the screen ran
|
|
1623
1848
|
const [screenedVerdicts, withheld] = screened, current = validate();
|
|
1624
1849
|
const finalSignals = current.drafts.map(part => this.signalsFor({ ...part, detail: [part.detail, planText(part.plan)].join("\n") }));
|
|
1625
1850
|
const deterministicPrivate = finalSignals.map((signals, index) => initialPrivate[index] || this.isPii({ signals }));
|
|
@@ -1627,6 +1852,8 @@ export class Tasks {
|
|
|
1627
1852
|
const inherited = current.drafts.map((_part, index) => (initialParentPrivate || this.isPii(current.parent)) && !deterministicPrivate[index]);
|
|
1628
1853
|
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
1854
|
for (const draft of drafts) this.warmRepository(draft);
|
|
1855
|
+
// #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).
|
|
1856
|
+
this.d.board.requireDraftForNextSplit = opts.intake === true && by === HUB;
|
|
1630
1857
|
const result = this.d.board.splitBacklog(id, by, revision, drafts, reason, withheld || initialReasonPrivate || this.isPii(current.parent) || !this.nameable(reason));
|
|
1631
1858
|
if (this.d.coordination) {
|
|
1632
1859
|
const mode = this.d.coordination();
|
|
@@ -1640,6 +1867,92 @@ export class Tasks {
|
|
|
1640
1867
|
return result;
|
|
1641
1868
|
}
|
|
1642
1869
|
|
|
1870
|
+
/**
|
|
1871
|
+
* 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
|
|
1872
|
+
* the grandchildren inherit the mark, so the walk follows `deps` through draft cards only: a dependency on ordinary work is never followed.
|
|
1873
|
+
*/
|
|
1874
|
+
private intakeFamily(id: number): Task[] {
|
|
1875
|
+
const root = this.d.board.get(id);
|
|
1876
|
+
if (!root || !root.draft || root.state !== "backlog") throw new PreEffectToolRefusal(`task #${id} is not an open intake draft`);
|
|
1877
|
+
const family: Task[] = [root], seen = new Set([root.id]);
|
|
1878
|
+
for (let next = 0; next < family.length; next++) {
|
|
1879
|
+
for (const dep of family[next]!.deps ?? []) {
|
|
1880
|
+
if (seen.has(dep)) continue;
|
|
1881
|
+
const child = this.d.board.get(dep);
|
|
1882
|
+
if (!child || !child.draft || child.state !== "backlog") continue;
|
|
1883
|
+
seen.add(dep); family.push(child);
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
return family;
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
/**
|
|
1890
|
+
* #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
|
|
1891
|
+
* `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
|
|
1892
|
+
* validate leaves the draft whole, with the reason on its history.
|
|
1893
|
+
*/
|
|
1894
|
+
async intake(by: PeerId, input: { text: unknown; sources?: unknown }): Promise<{ draft: Task; children: Task[]; split: "planner" | "hub" | "unsplit"; reason?: string }> {
|
|
1895
|
+
if (by !== USER) throw new PreEffectToolRefusal("intake is a console command");
|
|
1896
|
+
const text = typeof input.text === "string" ? input.text.trim() : "";
|
|
1897
|
+
if (!text || text.length > INTAKE_MAX_TEXT) throw new PreEffectToolRefusal(`intake needs a request of 1 to ${INTAKE_MAX_TEXT} characters`);
|
|
1898
|
+
const given = input.sources ?? [];
|
|
1899
|
+
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`);
|
|
1900
|
+
const sources = given as string[];
|
|
1901
|
+
for (const id of sources) if (!this.d.intake?.sourceKnown(id)) throw new PreEffectToolRefusal(`unknown source ${id}; register it with ahub source add`);
|
|
1902
|
+
const title = `Intake: ${text.split("\n")[0]!.replace(/\s+/g, " ").trim().slice(0, 80)}`;
|
|
1903
|
+
let draft = await this.propose(USER, { title, detail: intakeDetail(text, sources), class: "plan", backlog: true, draft: true });
|
|
1904
|
+
this.incomingBacklog.delete(draft.id);
|
|
1905
|
+
const priv = this.isPii(draft);
|
|
1906
|
+
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");
|
|
1907
|
+
if (planner) {
|
|
1908
|
+
const body = plannerRequestBody({ id: draft.id, revision: draft.planningRevision ?? 0, text: parseIntakeDetail(String(this.publicView(draft).detail)).text, sources });
|
|
1909
|
+
try {
|
|
1910
|
+
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" };
|
|
1911
|
+
} catch { /* the planner is not reachable: the hub model takes it */ }
|
|
1912
|
+
}
|
|
1913
|
+
return { ...(await this.intakeByHub(draft, text, sources, priv)), draft: this.d.board.get(draft.id) ?? draft };
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
/** The hub model's split of a draft, validated against the profile's `hub_backlog_split` parts schema and written through `splitBacklog`. */
|
|
1917
|
+
private async intakeByHub(draft: Task, text: string, sources: string[], priv: boolean): Promise<{ children: Task[]; split: "hub" | "unsplit"; reason?: string }> {
|
|
1918
|
+
const unsplit = (reason: string) => { this.d.board.update(draft.id, HUB, "intake unsplit", {}, reason); return { children: [], split: "unsplit" as const, reason }; };
|
|
1919
|
+
if (!this.d.intake) return unsplit("no planner is attached and the hub has no model for a split");
|
|
1920
|
+
// A request that is PII goes only to a model reached on campus; without one it stays whole for the person.
|
|
1921
|
+
if (priv && !(await this.d.triage?.onCampus().catch(() => false))) return unsplit("the request is PII and no on-prem model was reached");
|
|
1922
|
+
let raw: string | undefined;
|
|
1923
|
+
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; }
|
|
1924
|
+
const parsed = parseIntakeSplit(raw, this.d.intake.partsSchema(), sources);
|
|
1925
|
+
if ("error" in parsed) return unsplit(parsed.error);
|
|
1926
|
+
const current = this.d.board.get(draft.id);
|
|
1927
|
+
if (!current || !current.draft || current.state !== "backlog") return unsplit("the draft changed while the model answered");
|
|
1928
|
+
try {
|
|
1929
|
+
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 });
|
|
1930
|
+
return { children: result.children, split: "hub" };
|
|
1931
|
+
} catch (error) { return unsplit(`the split was refused: ${error instanceof PreEffectToolRefusal ? sanitize(error.message).slice(0, 200) : "the board refused it"}`); }
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
/** #334: the person approves a draft; it and its children leave draft and follow the normal backlog pull. */
|
|
1935
|
+
async approveIntake(by: PeerId, id: unknown): Promise<Task[]> {
|
|
1936
|
+
if (!this.personOrConductor(by)) throw new PreEffectToolRefusal("intake approval is a console command");
|
|
1937
|
+
if (typeof id !== "number" || !Number.isSafeInteger(id) || id <= 0) throw new PreEffectToolRefusal("id must be a positive task number");
|
|
1938
|
+
const family = this.intakeFamily(id);
|
|
1939
|
+
let approved: Task[];
|
|
1940
|
+
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}`); }
|
|
1941
|
+
catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
|
|
1942
|
+
for (const task of approved) this.incomingBacklog.add(task.id);
|
|
1943
|
+
this.releaseNotice(`intake draft #${id} approved: ${approved.length} backlog card${approved.length === 1 ? "" : "s"}`);
|
|
1944
|
+
return approved;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
/** #334: the person discards a draft; it and its children are cancelled through `cancel`. */
|
|
1948
|
+
async discardIntake(by: PeerId, id: unknown): Promise<Task[]> {
|
|
1949
|
+
if (by !== USER) throw new PreEffectToolRefusal("intake discard is a console command");
|
|
1950
|
+
if (typeof id !== "number" || !Number.isSafeInteger(id) || id <= 0) throw new PreEffectToolRefusal("id must be a positive task number");
|
|
1951
|
+
const cancelled: Task[] = [];
|
|
1952
|
+
for (const task of this.intakeFamily(id)) cancelled.push(await this.cancel(task.id, USER, { reason: "intake draft discarded by the person" }));
|
|
1953
|
+
return cancelled;
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1643
1956
|
/** Release assignment changes no execution role, dependency, reservation or state. */
|
|
1644
1957
|
async assignRelease(by: PeerId, input: { id: number; revision: number; release: number | null; reason: string }): Promise<Task> {
|
|
1645
1958
|
this.plannerWriteAuthority(by);
|
|
@@ -1789,12 +2102,14 @@ export class Tasks {
|
|
|
1789
2102
|
return updated; // a failed readback may follow a durable release write
|
|
1790
2103
|
}
|
|
1791
2104
|
|
|
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);
|
|
2105
|
+
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> {
|
|
2106
|
+
const requestCheck = this.requestCheck(by, fence);
|
|
2107
|
+
requireLocalTaskPeer(input.owner);
|
|
1794
2108
|
external = external ? { ...external } : undefined;
|
|
1795
|
-
if (external && (by
|
|
2109
|
+
if (external && (!this.personOrConductor(by) || input.backlog !== true)) throw new PreEffectToolRefusal("external intake requires person-owned backlog staging");
|
|
1796
2110
|
if (input.backlog !== undefined && typeof input.backlog !== "boolean") throw new PreEffectToolRefusal("backlog must be a boolean");
|
|
1797
2111
|
const backlog = input.backlog === true;
|
|
2112
|
+
if (input.draft !== undefined && (input.draft !== true || !backlog || by !== USER)) throw new PreEffectToolRefusal("only the person's intake stages a backlog draft");
|
|
1798
2113
|
const claiming = !backlog && input.owner === by;
|
|
1799
2114
|
if (claiming) {
|
|
1800
2115
|
this.acceptanceCapacity({ id: -1, owner: by, state: "proposed" });
|
|
@@ -1864,7 +2179,7 @@ export class Tasks {
|
|
|
1864
2179
|
this.warmRepository({ refs: draft.refs, plan });
|
|
1865
2180
|
const repositoryMismatch = !this.isPii({ signals }) && this.repositories.mismatch(draft.refs, plan);
|
|
1866
2181
|
requestCheck();
|
|
1867
|
-
let task = this.d.board.propose(by, { ...draft, plan, ...(external ? { external } : {}), ...(deps.length ? { deps } : {}), ...(reserved ? { reserved } : {}), signals, ...(backlog ? { backlog: true } : {}) });
|
|
2182
|
+
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
2183
|
if (repositoryMismatch) this.releaseNotice(`task #${task.id}: refs and plan name different repositories; check refs.repo before editing`);
|
|
1869
2184
|
if (this.d.coordination) this.coordinationModes.set(task.id, this.d.coordination());
|
|
1870
2185
|
created?.();
|
|
@@ -1934,6 +2249,7 @@ export class Tasks {
|
|
|
1934
2249
|
if (!Number.isSafeInteger(n) || n <= 0) throw new PreEffectToolRefusal("id must be a positive task number");
|
|
1935
2250
|
const task = this.d.board.get(n);
|
|
1936
2251
|
if (!task) throw new PreEffectToolRefusal(`no task #${n}`);
|
|
2252
|
+
if (pull && task.draft) throw new PreEffectToolRefusal(`task #${n} is an intake draft; approve it with ahub intake approve ${n} first`);
|
|
1937
2253
|
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
2254
|
if (!pull && taskHasStarted(task)) throw new PreEffectToolRefusal(`task #${n}: started work cannot return to backlog`);
|
|
1939
2255
|
const pulled = task.history.findLast(entry => entry.event === "backlog pulled" || entry.event === "backlog returned");
|
|
@@ -1977,7 +2293,8 @@ export class Tasks {
|
|
|
1977
2293
|
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
2294
|
};
|
|
1979
2295
|
const tries = (t: Task) => this.rescreens.get(t.id) ?? 0;
|
|
1980
|
-
const
|
|
2296
|
+
const rescreenMax = this.d.rescreenMax?.() ?? RESCREEN_MAX;
|
|
2297
|
+
const task = this.d.board.list("proposed").filter((t) => waiting(t) && tries(t) < rescreenMax).sort((a, b) => tries(a) - tries(b))[0];
|
|
1981
2298
|
if (!task) return;
|
|
1982
2299
|
this.rescreens.set(task.id, (this.rescreens.get(task.id) ?? 0) + 1);
|
|
1983
2300
|
const text = screenText(task);
|
|
@@ -2117,6 +2434,7 @@ export class Tasks {
|
|
|
2117
2434
|
* recipient, and carries `completedFor` so the bus replaces an earlier queued notice for the same `done` task and recipient.
|
|
2118
2435
|
*/
|
|
2119
2436
|
whileOpenFor(to: PeerId, done: number, about: number[], body: string): void {
|
|
2437
|
+
requireLocalTaskPeer(to);
|
|
2120
2438
|
const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(about[0]), taskRecipient: "owner", completedFor: done } });
|
|
2121
2439
|
this.conditional.set(env.id, { peer: to, task: about[0]!, tasks: about, states: OPEN });
|
|
2122
2440
|
if (this.conditional.size > 1024) this.conditional.delete(this.conditional.keys().next().value as string);
|
|
@@ -2124,6 +2442,7 @@ export class Tasks {
|
|
|
2124
2442
|
}
|
|
2125
2443
|
|
|
2126
2444
|
whileOpen(to: PeerId, task: number, body: string, states: Task["state"][] = OPEN): void {
|
|
2445
|
+
requireLocalTaskPeer(to);
|
|
2127
2446
|
const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(task), taskRecipient: "owner" } });
|
|
2128
2447
|
this.conditional.set(env.id, { peer: to, task, states });
|
|
2129
2448
|
if (this.conditional.size > 1024) this.conditional.delete(this.conditional.keys().next().value as string);
|
|
@@ -2262,6 +2581,7 @@ export class Tasks {
|
|
|
2262
2581
|
* Only when some other peer can take a task: with nobody to hand it to, it stays with its owner.
|
|
2263
2582
|
*/
|
|
2264
2583
|
async releaseFromGone(peer: PeerId, minutes: number): Promise<{ id: number; title: string; to: PeerId | null }[]> {
|
|
2584
|
+
requireLocalTaskPeer(peer);
|
|
2265
2585
|
const moved: { id: number; title: string; to: PeerId | null }[] = [];
|
|
2266
2586
|
const why = `owner ${peer} offline for ${minutes} min`;
|
|
2267
2587
|
const ids = this.d.board.list().filter((t) => t.owner === peer && OPEN.includes(t.state)).map((t) => t.id);
|
|
@@ -2334,11 +2654,12 @@ export class Tasks {
|
|
|
2334
2654
|
}
|
|
2335
2655
|
}
|
|
2336
2656
|
|
|
2337
|
-
/**
|
|
2657
|
+
/** Authorized manual owner removal sticks until a later explicit assignment/reservation, across hub runs. */
|
|
2338
2658
|
private personUnassigned(task: Task): boolean {
|
|
2339
|
-
const
|
|
2659
|
+
const manual = (h: HistoryEntry) => h.by === USER || h.conductorManual === true;
|
|
2660
|
+
const removed = task.history.findLastIndex((h) => h.event === "unassigned" && manual(h));
|
|
2340
2661
|
if (removed < 0) return false;
|
|
2341
|
-
return !task.history.slice(removed + 1).some((h) => h
|
|
2662
|
+
return !task.history.slice(removed + 1).some((h) => manual(h) && ["assigned", "reassigned", "escalated", "reserved", "backlog pulled"].includes(h.event));
|
|
2342
2663
|
}
|
|
2343
2664
|
|
|
2344
2665
|
/**
|
|
@@ -2390,7 +2711,7 @@ export class Tasks {
|
|
|
2390
2711
|
}
|
|
2391
2712
|
|
|
2392
2713
|
/** `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> {
|
|
2714
|
+
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
2715
|
opts.current?.();
|
|
2395
2716
|
if (task.state === "backlog" && !opts.plannerPull) throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
|
|
2396
2717
|
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
@@ -2420,7 +2741,9 @@ export class Tasks {
|
|
|
2420
2741
|
const waits = this.waitsFor(task);
|
|
2421
2742
|
const plannedMove = opts.planner || this.plannerPullRecord(task)?.pull === true && (by === HUB || opts.roster) && (opts.reason !== "manual" || opts.roster);
|
|
2422
2743
|
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 } : {})
|
|
2744
|
+
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.
|
|
2745
|
+
contextOverride: !!(opts.queueOverride || opts.claim),
|
|
2746
|
+
...(opts.queueOverride || opts.claim ? { queueOverride: opts.queueOverride ?? "owner" as const } : {}) });
|
|
2424
2747
|
|
|
2425
2748
|
if (waits.length) {
|
|
2426
2749
|
if (opts.plannerPull) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
|
|
@@ -2434,14 +2757,18 @@ export class Tasks {
|
|
|
2434
2757
|
const why = a.refusals?.find(row => row.role === "owner" && row.peer === opts.namedOwner)?.reason ?? "not eligible";
|
|
2435
2758
|
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
2759
|
}
|
|
2437
|
-
if (!task.owner && task.state === "proposed") this.reportStall(task, this.ownerStallCause(task, a));
|
|
2438
2760
|
this.releaseNotice(opts.roster
|
|
2439
2761
|
? `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
2762
|
: `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
2763
|
// Only a decline takes the task away from its owner; a failed console assign or escalation leaves it where it was.
|
|
2442
2764
|
if (opts.clearOnFail && task.owner) {
|
|
2443
2765
|
this.cohorts.leave(task.id);
|
|
2444
|
-
|
|
2766
|
+
task = this.d.board.update(task.id, by, "unassigned", { owner: null });
|
|
2767
|
+
}
|
|
2768
|
+
if (!task.owner && task.state === "proposed") {
|
|
2769
|
+
const cause = this.ownerStallCause(task, a, !opts.claim);
|
|
2770
|
+
// A named proposal remains bound to that target when pressure clears.
|
|
2771
|
+
this.reportStall(task, cause.kind === "no-eligible-peer" && isQueuePressureCode(cause.code) && opts.namedOwner ? { ...cause, peer: opts.namedOwner } : cause);
|
|
2445
2772
|
}
|
|
2446
2773
|
return task;
|
|
2447
2774
|
}
|
|
@@ -2452,11 +2779,12 @@ export class Tasks {
|
|
|
2452
2779
|
opts.plannerPull?.guard();
|
|
2453
2780
|
const assignmentNote = this.assignmentNote(`${opts.note ?? `to ${a.owner}`}${passed}`, a);
|
|
2454
2781
|
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! } : {}) };
|
|
2782
|
+
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
2783
|
opts.current?.();
|
|
2457
2784
|
const next = opts.plannerPull
|
|
2458
2785
|
? 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
2786
|
: 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);
|
|
2787
|
+
this.queueReviewWritten(next);
|
|
2460
2788
|
if (opts.plannerPull) this.planningRecorded({ by, task: next.id, fields: ["pull", "reserved"], revision: next.planningRevision! });
|
|
2461
2789
|
// What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
|
|
2462
2790
|
// escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
|
|
@@ -2523,11 +2851,16 @@ export class Tasks {
|
|
|
2523
2851
|
return next;
|
|
2524
2852
|
}
|
|
2525
2853
|
|
|
2526
|
-
/**
|
|
2854
|
+
/** #933: the person, or the configured conductor inside an admitted operation (`TasksDeps.operational` rechecks the role). */
|
|
2855
|
+
private personOrConductor(by: PeerId): boolean { return by === USER || this.d.operational?.(by) === true; }
|
|
2856
|
+
|
|
2857
|
+
/** Advice changes by the person or, #933, the task's own owner never change ownership, class or authority. */
|
|
2527
2858
|
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
2859
|
if (raw === undefined) throw new PreEffectToolRefusal("name a catalog role id or explicit null to clear it");
|
|
2530
2860
|
const task = this.need(id, false, true);
|
|
2861
|
+
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`);
|
|
2862
|
+
// #933: under review the role is what the reviewer reviews against; only the person changes it then.
|
|
2863
|
+
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
2864
|
if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("a closed task's role is historical; propose new work instead");
|
|
2532
2865
|
let selected;
|
|
2533
2866
|
try { selected = taskRole(raw === null ? undefined : raw, task.class, this.d.catalog ?? loadRoleCatalog(this.d.cwd)); }
|
|
@@ -2537,7 +2870,7 @@ export class Tasks {
|
|
|
2537
2870
|
for (const peer of new Set([next.owner, next.state === "in_review" ? next.reviewer : null])) {
|
|
2538
2871
|
if (!peer || peer === USER) continue;
|
|
2539
2872
|
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."}`,
|
|
2873
|
+
`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
2874
|
{ to: [peer], kind: "task", refs: { task: String(next.id), taskRecipient: peer === next.reviewer ? "reviewer" : "owner" }, ...(this.isPii(next) ? { private: true } : {}) })));
|
|
2542
2875
|
}
|
|
2543
2876
|
return next;
|
|
@@ -2545,6 +2878,7 @@ export class Tasks {
|
|
|
2545
2878
|
|
|
2546
2879
|
/** A self-claim has no assignment envelope; its tool result carries the same bounded guidance. */
|
|
2547
2880
|
claimRoleGuidance(task: Task, peer: PeerId): string {
|
|
2881
|
+
requireLocalTaskPeer(peer);
|
|
2548
2882
|
return task.owner === peer && task.state === "in_progress" ? this.roleGuidance(task, peer) : "";
|
|
2549
2883
|
}
|
|
2550
2884
|
|
|
@@ -2707,7 +3041,8 @@ export class Tasks {
|
|
|
2707
3041
|
try { return this.d.offerRead?.(offer.peer, offer.envelopeId) !== true; } catch { return true; }
|
|
2708
3042
|
}).map(offer => ({ ...offer }));
|
|
2709
3043
|
}
|
|
2710
|
-
|
|
3044
|
+
/** A status-only snapshot is advisory; all effect/admission callers retain fresh health by default. */
|
|
3045
|
+
stalled(projection?: { ownerIsolation: Readonly<Record<string, boolean>> }): TaskStall[] {
|
|
2711
3046
|
const revision = this.d.board.statusRevision();
|
|
2712
3047
|
if (this.stallCandidates?.revision !== revision) this.stallCandidates = { revision, rows: this.d.board.stalls().flatMap(stall => {
|
|
2713
3048
|
const task = this.d.board.get(stall.task);
|
|
@@ -2717,37 +3052,213 @@ export class Tasks {
|
|
|
2717
3052
|
return this.waitsFor(task).length ? [] : [{ stall, task, planned: this.plannerPullRecord(task)?.pull === true }];
|
|
2718
3053
|
}) };
|
|
2719
3054
|
// Eligibility inputs are live; queue journal writes alone do not re-decode board histories.
|
|
2720
|
-
const
|
|
3055
|
+
const health = this.health(projection?.ownerIsolation);
|
|
3056
|
+
const key = JSON.stringify([revision, this.states(), this.d.routing(), health, [...this.d.bus.peers.keys()].map(peer => {
|
|
2721
3057
|
const queue = this.d.bus.queueSummary(peer);
|
|
2722
3058
|
return [peer, this.wipLimit(peer), queue.heldBy, queue.needsReview];
|
|
2723
3059
|
})]);
|
|
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) };
|
|
3060
|
+
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
3061
|
return structuredClone(this.stallView.stalls);
|
|
2726
3062
|
}
|
|
3063
|
+
private queuePressureKnown: boolean | undefined;
|
|
3064
|
+
private queuePressureGeneration = 0;
|
|
3065
|
+
private queuePressureQueued = false;
|
|
3066
|
+
private queuePressureRunning = false;
|
|
3067
|
+
private queuePressureDirty = false;
|
|
3068
|
+
private queuePressureStopped = false;
|
|
3069
|
+
private queuePressurePass?: QueuePressurePass;
|
|
3070
|
+
private queuePressureContinuation?: ReturnType<typeof setImmediate>;
|
|
3071
|
+
|
|
3072
|
+
private markQueuePressureCandidate(): void {
|
|
3073
|
+
this.queuePressureKnown = true; this.queuePressureGeneration++;
|
|
3074
|
+
this.scheduleQueuePressureRecheck();
|
|
3075
|
+
}
|
|
3076
|
+
private queueReviewWritten(task: Task): void {
|
|
3077
|
+
if (task.state === "in_review" && task.reviewer === USER && task.history.at(-1)?.queueReviewHeld) this.markQueuePressureCandidate();
|
|
3078
|
+
}
|
|
3079
|
+
private queuePressureAdmitted(): boolean {
|
|
3080
|
+
try { return !this.queuePressureStopped && !this.d.sweepHeld?.() && !this.d.bus.isRecoveryHeld && !this.d.bus.storageError; }
|
|
3081
|
+
catch { return false; }
|
|
3082
|
+
}
|
|
3083
|
+
/** Run after the bus's synchronous queue checkpoint, coalescing notifications without polling. */
|
|
3084
|
+
scheduleQueuePressureRecheck(): void {
|
|
3085
|
+
if (this.queuePressureStopped || this.queuePressureKnown === false) return;
|
|
3086
|
+
this.queuePressureDirty = true;
|
|
3087
|
+
if (this.queuePressureQueued || this.queuePressureRunning || this.queuePressureContinuation) return;
|
|
3088
|
+
this.queuePressureQueued = true;
|
|
3089
|
+
queueMicrotask(() => {
|
|
3090
|
+
this.queuePressureQueued = false;
|
|
3091
|
+
if (!this.queuePressureAdmitted()) return;
|
|
3092
|
+
this.queuePressureDirty = false;
|
|
3093
|
+
const pass = this.queuePressurePass ??= { ownerAfter: 0, reviewAfter: 0, ownerDone: false, reviewDone: false, retained: false, generation: this.queuePressureGeneration };
|
|
3094
|
+
this.queuePressureRunning = true;
|
|
3095
|
+
let failed = false;
|
|
3096
|
+
void this.recheckQueuePressureBatch(pass).catch(() => {
|
|
3097
|
+
failed = true;
|
|
3098
|
+
// A failed/unknown metadata or authority read never creates an assignment.
|
|
3099
|
+
this.queuePressureKnown = true; this.queuePressurePass = undefined;
|
|
3100
|
+
}).finally(() => {
|
|
3101
|
+
this.queuePressureRunning = false;
|
|
3102
|
+
if (!this.queuePressureAdmitted()) { this.queuePressurePass = undefined; return; }
|
|
3103
|
+
if (failed) return;
|
|
3104
|
+
if (this.queuePressurePass && (!pass.ownerDone || !pass.reviewDone)) {
|
|
3105
|
+
this.queuePressureContinuation = setImmediate(() => { this.queuePressureContinuation = undefined; this.scheduleQueuePressureRecheck(); });
|
|
3106
|
+
this.queuePressureContinuation.unref?.();
|
|
3107
|
+
return;
|
|
3108
|
+
}
|
|
3109
|
+
this.queuePressurePass = undefined;
|
|
3110
|
+
const changed = pass.generation !== this.queuePressureGeneration;
|
|
3111
|
+
this.queuePressureKnown = pass.retained || changed;
|
|
3112
|
+
if ((this.queuePressureDirty || changed) && this.queuePressureKnown) this.scheduleQueuePressureRecheck();
|
|
3113
|
+
});
|
|
3114
|
+
});
|
|
3115
|
+
}
|
|
3116
|
+
/** Recovery waits for queued/running host writers; a stop fences a writer already in an await. */
|
|
3117
|
+
queuePressureRechecksPending = (): number => Number(this.queuePressureQueued || this.queuePressureRunning || this.queuePressureContinuation !== undefined);
|
|
3118
|
+
stopQueuePressureRechecks(): void {
|
|
3119
|
+
this.queuePressureStopped = true; this.queuePressureDirty = false; this.queuePressureQueued = false;
|
|
3120
|
+
if (this.queuePressureContinuation) clearImmediate(this.queuePressureContinuation);
|
|
3121
|
+
this.queuePressureContinuation = undefined; this.queuePressurePass = undefined;
|
|
3122
|
+
}
|
|
3123
|
+
private ordinaryQueueProposal(stall: TaskStall, task: Task | undefined): task is Task {
|
|
3124
|
+
return !!task && task.state === "proposed" && !task.owner && !task.draft
|
|
3125
|
+
&& !this.waitsFor(task).length && !this.personUnassigned(task) && !this.plannerPullRecord(task)?.pull;
|
|
3126
|
+
}
|
|
3127
|
+
private queueReviewBinding(task: Task): string {
|
|
3128
|
+
return createHash("sha256").update(JSON.stringify([this.reviewHead(task) ?? null, task.refs.repo ?? null, task.sdlc?.repo ?? null, task.sdlc?.pr ?? null])).digest("hex");
|
|
3129
|
+
}
|
|
3130
|
+
private queueReviewHeld(task: Task): boolean {
|
|
3131
|
+
if (task.state !== "in_review" || task.reviewer !== USER || this.isPii(task) || this.manuallyNamedReviewer(task)) return false;
|
|
3132
|
+
const role = task.history[this.reviewRequestRevision(task) - 1];
|
|
3133
|
+
return role?.queueReviewHeld === true && role.queueReviewBinding === this.queueReviewBinding(task);
|
|
3134
|
+
}
|
|
3135
|
+
private async recheckQueuePressureBatch(pass: QueuePressurePass): Promise<void> {
|
|
3136
|
+
if (!this.queuePressureAdmitted()) return;
|
|
3137
|
+
if (!pass.ownerDone) {
|
|
3138
|
+
const page = this.d.board.queuePressureStallPage(pass.ownerAfter, QUEUE_RECHECK_PAGE);
|
|
3139
|
+
pass.ownerAfter = page.afterTask; pass.ownerDone = page.done;
|
|
3140
|
+
for (const stall of page.rows) {
|
|
3141
|
+
if (!this.queuePressureAdmitted()) return;
|
|
3142
|
+
const task = this.d.board.get(stall.task);
|
|
3143
|
+
if (!this.ordinaryQueueProposal(stall, task)) { this.d.board.clearStall(stall.task); continue; }
|
|
3144
|
+
// A planning revision asks for fresh admission, never erases an automatic offer.
|
|
3145
|
+
const namedOwner = stall.cause.peer;
|
|
3146
|
+
const retry = namedOwner ? { candidates: [namedOwner], namedOwner } : {};
|
|
3147
|
+
const choice = assign(task, this.states(), this.d.routing(), { exclude: this.excluded(task), waitsFor: this.waitsFor(task), ...this.weights(task.class, task), ...retry });
|
|
3148
|
+
if (choice.owner) {
|
|
3149
|
+
const current = () => { if (!this.queuePressureAdmitted()) throw new PreEffectToolRefusal("queue-pressure retry is no longer admitted"); };
|
|
3150
|
+
try {
|
|
3151
|
+
// Reserved ready work shares the existing WIP, in-flight slot and offered fences.
|
|
3152
|
+
if (task.reserved) await this.offerReady(task, "queue pressure cleared", current);
|
|
3153
|
+
else await this.assignOwner(task, HUB, { current, ...retry });
|
|
3154
|
+
} catch (error) {
|
|
3155
|
+
if (!(error instanceof AssignmentUndeliveredError) && this.queuePressureAdmitted()) pass.retained = true;
|
|
3156
|
+
}
|
|
3157
|
+
if (!this.queuePressureAdmitted()) return;
|
|
3158
|
+
} // Pure transient eligibility reads keep the origin; only a gate that owns retries replaces it.
|
|
3159
|
+
const latest = this.d.board.get(task.id);
|
|
3160
|
+
if (this.ordinaryQueueProposal(stall, latest)) {
|
|
3161
|
+
const origin = this.d.board.queuePressureStallPage(task.id - 1, 1).rows.find(row => row.task === task.id);
|
|
3162
|
+
if (this.isHeld(latest)) this.d.board.clearStall(task.id); // WIP release owns its ready-held task now.
|
|
3163
|
+
else if (origin) {
|
|
3164
|
+
this.d.board.recordStall({ ...origin, replanRevision: latest.planningRevision ?? 0 });
|
|
3165
|
+
pass.retained = true;
|
|
3166
|
+
} else {
|
|
3167
|
+
const replacing = this.d.board.stall(task.id);
|
|
3168
|
+
const selected = choice.owner;
|
|
3169
|
+
const refusal = replacing && selected ? replacing.cause.peer === selected ? replacing.cause.code : replacing.cause.refusals?.find(row => row.peer === selected)?.code : undefined;
|
|
3170
|
+
const transient = replacing && (replacing.cause.kind === "no-eligible-peer" || replacing.cause.kind === "reservation") && replacing.cause.code !== "policy" && (refusal === "offline" || refusal === "not-attached");
|
|
3171
|
+
if (transient) {
|
|
3172
|
+
// The initially eligible owner vanished during admission; a policy gate never reaches this restore.
|
|
3173
|
+
this.d.board.recordStall({ ...stall, replanRevision: latest.planningRevision ?? 0 });
|
|
3174
|
+
pass.retained = true;
|
|
3175
|
+
}
|
|
3176
|
+
} // Other replacing gates keep their stall and own any later retry.
|
|
3177
|
+
} else this.d.board.clearStall(task.id);
|
|
3178
|
+
}
|
|
3179
|
+
}
|
|
3180
|
+
if (!pass.reviewDone && this.queuePressureAdmitted()) {
|
|
3181
|
+
const ids = this.d.board.humanReviewPage(pass.reviewAfter, QUEUE_RECHECK_PAGE);
|
|
3182
|
+
pass.reviewAfter = ids.at(-1) ?? pass.reviewAfter; pass.reviewDone = ids.length < QUEUE_RECHECK_PAGE;
|
|
3183
|
+
for (const id of ids) {
|
|
3184
|
+
if (!this.queuePressureAdmitted()) return;
|
|
3185
|
+
const task = this.d.board.get(id);
|
|
3186
|
+
if (!task || !this.queueReviewHeld(task)) continue;
|
|
3187
|
+
const reviewer = await this.recheckQueueReview(task);
|
|
3188
|
+
if (!this.queuePressureAdmitted()) return;
|
|
3189
|
+
const latest = this.d.board.get(id);
|
|
3190
|
+
if (!reviewer && latest && this.queueReviewHeld(latest)) pass.retained = true;
|
|
3191
|
+
}
|
|
3192
|
+
}
|
|
3193
|
+
}
|
|
3194
|
+
private async recheckQueueReview(before: Task): Promise<PeerId | undefined> {
|
|
3195
|
+
const revision = this.reviewRequestRevision(before), head = this.reviewHead(before), owner = before.owner;
|
|
3196
|
+
const valid = () => {
|
|
3197
|
+
if (!this.queuePressureAdmitted()) throw new PreEffectToolRefusal("queue-pressure retry is no longer admitted");
|
|
3198
|
+
const task = this.need(before.id, false, true);
|
|
3199
|
+
if (!this.queueReviewHeld(task) || this.reviewRequestRevision(task) !== revision || this.reviewHead(task) !== head || task.owner !== owner) throw new PreEffectToolRefusal("queue-held review changed");
|
|
3200
|
+
return task;
|
|
3201
|
+
};
|
|
3202
|
+
const choose = (task: Task) => assign(task, this.states(), this.d.routing(), { ...this.weights(task.class, task), candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}) });
|
|
3203
|
+
const initial = choose(valid());
|
|
3204
|
+
if (!initial.reviewer || initial.reviewer === USER || initial.reviewBlocked) return undefined;
|
|
3205
|
+
try {
|
|
3206
|
+
const admission = await this.reviewRequestAdmission(before, undefined, true, before.owner ?? USER, undefined, () => { valid(); return true; });
|
|
3207
|
+
const task = valid(), selection = choose(task), reviewer = selection.reviewer;
|
|
3208
|
+
if (!reviewer || reviewer === USER || selection.reviewBlocked) return undefined;
|
|
3209
|
+
valid();
|
|
3210
|
+
const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, this.assignmentNote("queue pressure cleared", selection), this.reviewMetadata(selection), task.history.length);
|
|
3211
|
+
this.heldReviews.delete(next.id);
|
|
3212
|
+
// The current head is unchanged; its watcher reads the live reviewer at completion.
|
|
3213
|
+
this.releaseNotice(`task #${next.id}: queued review request unavailable`, () => this.sendReview(next, reviewer, "", admission));
|
|
3214
|
+
return reviewer;
|
|
3215
|
+
} catch { return undefined; }
|
|
3216
|
+
}
|
|
3217
|
+
|
|
2727
3218
|
/** 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
3219
|
namedOwnerNote(task: Task, named: PeerId | undefined, by: PeerId): string | undefined {
|
|
3220
|
+
requireLocalTaskPeer(by);
|
|
2729
3221
|
if (!named || task.owner || task.state !== "proposed") return undefined;
|
|
2730
3222
|
const stall = this.d.board.stalls().find(row => row.task === task.id);
|
|
2731
3223
|
if (!stall) return undefined;
|
|
2732
3224
|
const code = stall.cause.refusals?.find(row => row.peer === named)?.code ?? (stall.cause.peer === named ? stall.cause.code : undefined);
|
|
2733
3225
|
return `${named} was not assigned${code ? ` (${code})` : ""}: ${stall.cause.kind}; assign it again with hub_task_assign once ${named} can take it`;
|
|
2734
3226
|
}
|
|
2735
|
-
private ownerStallCause(task: Task, admission: Assignment): TaskStall["cause"] {
|
|
3227
|
+
private ownerStallCause(task: Task, admission: Assignment, automatic = true): TaskStall["cause"] {
|
|
2736
3228
|
const refusal = admission.refusals?.find(row => row.role === "owner" && row.peer === task.reserved);
|
|
2737
3229
|
// 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
|
-
|
|
3230
|
+
const persisted = (code: NonNullable<Assignment["refusals"]>[number]["code"]): TaskStall["cause"]["code"] => code === "context-headroom" || code === "instance" || code === "family" ? "policy" : code;
|
|
3231
|
+
const pressure = automatic ? admission.refusals?.find(row => row.role === "owner" && isQueuePressureCode(row.code)) : undefined;
|
|
3232
|
+
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
3233
|
}
|
|
2741
3234
|
private reportStall(task: Task, cause: TaskStall["cause"], now = Date.now(), description?: string): void {
|
|
2742
3235
|
const stall: TaskStall = { task: task.id, cause, signature: JSON.stringify(cause), replanRevision: task.planningRevision ?? 0, at: now };
|
|
2743
3236
|
if (!this.d.board.recordStall(stall)) return;
|
|
3237
|
+
if (cause.kind === "no-eligible-peer" && isQueuePressureCode(cause.code)) this.markQueuePressureCandidate();
|
|
2744
3238
|
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
3239
|
this.releaseNotice(text);
|
|
2746
3240
|
try { this.d.stallNotice?.(stall); } catch { /* advisory never changes task authority */ }
|
|
2747
3241
|
// Advice joins the planner's next delivery; it must not manufacture a turn beside a withheld backlog batch.
|
|
2748
3242
|
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
3243
|
}
|
|
3244
|
+
/**
|
|
3245
|
+
* #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
|
|
3246
|
+
* 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
|
|
3247
|
+
* 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).
|
|
3248
|
+
*/
|
|
3249
|
+
recordClaimVerdict(by: PeerId, id: unknown, input: unknown): Task {
|
|
3250
|
+
if (by !== USER) throw new Error("only the console user may record a claim verdict");
|
|
3251
|
+
if (this.d.cancellationHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) throw new Error("recovery or shutdown is holding task writes");
|
|
3252
|
+
const task = this.need(id);
|
|
3253
|
+
const claims = task.plan?.claims?.length ?? 0;
|
|
3254
|
+
if (!claims) throw new Error(`task #${task.id} declares no plan claims: add them to its plan first`);
|
|
3255
|
+
const claim = parseClaimVerdict(input, claims);
|
|
3256
|
+
// 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.
|
|
3257
|
+
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
|
|
3258
|
+
}
|
|
3259
|
+
|
|
2750
3260
|
async cancel(id: unknown, by: PeerId, input: { reason: string; supersededBy?: string; expectedFlowRevision?: number }): Promise<Task> {
|
|
3261
|
+
requireLocalTaskPeer(by);
|
|
2751
3262
|
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
3263
|
const validate = () => {
|
|
2753
3264
|
if (this.d.cancellationHeld?.() || this.d.bus.isRecoveryHeld) throw new PreEffectToolRefusal("recovery or shutdown is holding cancellation");
|
|
@@ -2795,15 +3306,21 @@ export class Tasks {
|
|
|
2795
3306
|
}
|
|
2796
3307
|
|
|
2797
3308
|
async releaseReady(): Promise<void> {
|
|
3309
|
+
const haveQueueOrigins = this.queuePressureKnown !== false && this.d.board.queuePressureStallPage(0, 1).afterTask > 0;
|
|
2798
3310
|
for (const t of [...this.d.board.list("proposed")].sort(this.readyOrder)) {
|
|
2799
3311
|
if (t.owner) { this.d.board.clearStall(t.id); continue; }
|
|
2800
3312
|
const cancelledDependency = t.deps?.find(id => this.d.board.get(id)?.state === "cancelled");
|
|
2801
3313
|
if (cancelledDependency) { this.reportStall(t, { kind: "cancelled-dependency", dependency: cancelledDependency, code: "cancelled" }); continue; }
|
|
2802
3314
|
const last = t.history.findLast(entry => !QUIET_EVENTS.has(entry.event))?.event, planned = this.plannerPullRecord(t)?.pull === true;
|
|
2803
3315
|
if (this.personUnassigned(t) || this.offered.has(t.id) || this.waitsFor(t).length) continue;
|
|
2804
|
-
|
|
3316
|
+
if (haveQueueOrigins) {
|
|
3317
|
+
const origin = this.d.board.queuePressureStallPage(t.id - 1, 1).rows.find(row => row.task === t.id);
|
|
3318
|
+
if (origin && this.ordinaryQueueProposal(origin, t)) { this.scheduleQueuePressureRecheck(); continue; }
|
|
3319
|
+
}
|
|
3320
|
+
const admission = planned ? this.plannerAssignment(t, true) : assign(t, this.states(), this.d.routing(), { exclude: this.excluded(t), ...this.weights(t.class, t) });
|
|
2805
3321
|
if (!admission.owner) {
|
|
2806
|
-
|
|
3322
|
+
// A diagnostic legacy pass is not evidence that an automatic proposal was attempted.
|
|
3323
|
+
this.reportStall(t, this.ownerStallCause(t, admission, this.hasPersonReservation(t)));
|
|
2807
3324
|
continue;
|
|
2808
3325
|
}
|
|
2809
3326
|
this.d.board.clearStall(t.id);
|
|
@@ -2815,7 +3332,8 @@ export class Tasks {
|
|
|
2815
3332
|
}
|
|
2816
3333
|
|
|
2817
3334
|
/** 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> {
|
|
3335
|
+
private async offerReady(stale: Task, why: string, current?: () => void): Promise<void> {
|
|
3336
|
+
current?.();
|
|
2819
3337
|
let offering = false, undelivered: AssignmentUndeliveredError | undefined, slot: PeerId | undefined;
|
|
2820
3338
|
try {
|
|
2821
3339
|
const t = this.d.board.get(stale.id);
|
|
@@ -2832,7 +3350,7 @@ export class Tasks {
|
|
|
2832
3350
|
offering = true;
|
|
2833
3351
|
const ready = t.history.at(-1)?.event === "ready" ? t : this.d.board.update(t.id, HUB, "ready", {}, why);
|
|
2834
3352
|
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 } : {});
|
|
3353
|
+
await this.assignOwner(ready, HUB, { current, ...(planned ? { candidates: [selected!], namedOwner: selected!, planner: true } : {}) });
|
|
2836
3354
|
} catch (e) {
|
|
2837
3355
|
// A saved move whose delivery failed is not "could not be assigned" (#297); the console notice was printed
|
|
2838
3356
|
// where the error was thrown. The offer stays consumed by the error's own evidence, without the readback.
|
|
@@ -2852,6 +3370,7 @@ export class Tasks {
|
|
|
2852
3370
|
}
|
|
2853
3371
|
|
|
2854
3372
|
private mine(task: Task, by: PeerId, role: "owner" | "reviewer", preEffect = false): void {
|
|
3373
|
+
requireLocalTaskPeer(by);
|
|
2855
3374
|
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
3375
|
}
|
|
2857
3376
|
|
|
@@ -2874,6 +3393,31 @@ export class Tasks {
|
|
|
2874
3393
|
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
3394
|
}
|
|
2876
3395
|
|
|
3396
|
+
/**
|
|
3397
|
+
* #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
|
|
3398
|
+
* 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
|
|
3399
|
+
* 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).
|
|
3400
|
+
*/
|
|
3401
|
+
submoduleNotice(task: Task): string | undefined {
|
|
3402
|
+
if (this.isPii(task)) return undefined;
|
|
3403
|
+
let submodules: string[];
|
|
3404
|
+
try { submodules = this.d.submodules?.() ?? []; } catch { return undefined; }
|
|
3405
|
+
if (!submodules.length) return undefined;
|
|
3406
|
+
if (this.repositories.scope(task.refs) !== this.repositories.project) return undefined;
|
|
3407
|
+
const declared = [...(task.refs.paths ?? []), ...(task.plan?.paths ?? []), ...(task.plan?.sections ?? []).map(anchor => anchor.split("#")[0]!).filter(Boolean)];
|
|
3408
|
+
const inside = new Set<string>();
|
|
3409
|
+
for (const path of declared) {
|
|
3410
|
+
let local: string;
|
|
3411
|
+
try { local = this.projectPath(path); } catch { continue; }
|
|
3412
|
+
if (local === ".." || local.startsWith("../") || isAbsolute(local)) continue;
|
|
3413
|
+
const hit = submodules.find(sub => local === sub || local.startsWith(`${sub}/`));
|
|
3414
|
+
if (hit) inside.add(hit);
|
|
3415
|
+
}
|
|
3416
|
+
const named = [...inside].filter(this.nameable).sort();
|
|
3417
|
+
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;
|
|
3418
|
+
return named.map(path => `inside submodule ${path}: facts, overlap and undo do not observe it`).join("\n");
|
|
3419
|
+
}
|
|
3420
|
+
|
|
2877
3421
|
/** #694: write the one issue the task itself names (the plan-review source) into its lifecycle receipt, once; never relinks. */
|
|
2878
3422
|
private linkIssue(task: Task): Task {
|
|
2879
3423
|
if (this.isPii(task) || task.sdlc?.issue) return task;
|
|
@@ -3028,7 +3572,53 @@ export class Tasks {
|
|
|
3028
3572
|
const byKind: Record<string, number> = {};
|
|
3029
3573
|
for (const stall of this.stalled()) if (visibleIds.has(stall.task)) byKind[stall.cause.kind] = (byKind[stall.cause.kind] ?? 0) + 1;
|
|
3030
3574
|
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
|
-
|
|
3575
|
+
// #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.
|
|
3576
|
+
const mergedAt: Record<number, number> = {}, ciReruns: Record<number, number | null> = {}, ciRuns: Record<number, number | null> = {}, reviews: Record<number, number | null> = {};
|
|
3577
|
+
for (const task of rows) {
|
|
3578
|
+
const merge = task.sdlc?.merge;
|
|
3579
|
+
if (!visibleIds.has(task.id) || merge?.state !== "merged") continue;
|
|
3580
|
+
const cost = cleanMergeCost(merge.cost);
|
|
3581
|
+
const at = cost?.mergedAt ?? (Number.isSafeInteger(merge.at) ? merge.at : null);
|
|
3582
|
+
if (at !== null) mergedAt[task.id] = at;
|
|
3583
|
+
if (cost) { ciReruns[task.id] = cost.ciReruns; ciRuns[task.id] = cost.ciRuns; reviews[task.id] = cost.reviews; }
|
|
3584
|
+
}
|
|
3585
|
+
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 ?? {}) });
|
|
3586
|
+
}
|
|
3587
|
+
|
|
3588
|
+
/** #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. */
|
|
3589
|
+
private externalSeries(): Partial<Record<BandMetric, SeriesPoint[]>> {
|
|
3590
|
+
const rows = this.d.board.list(), privateRelease = (id: number) => rows.some(task => task.release === id && this.isPii(task));
|
|
3591
|
+
const hotfix = this.d.board.releases().filter(release => release.state === "shipped" && release.shippedAt !== null && release.train?.history.length && !privateRelease(release.id))
|
|
3592
|
+
.sort((a, b) => a.shippedAt! - b.shippedAt! || a.id - b.id).map(release => ({ value: release.train!.hotfix ? 1 : 0, task: release.id }));
|
|
3593
|
+
const config = this.d.sdlc?.config().securityScan, repo = config?.repo ?? this.d.repository;
|
|
3594
|
+
// 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
|
|
3595
|
+
// totals (unknown ones are filtered in SQL, never counted against the limit). The board caps one read at 1000 rows.
|
|
3596
|
+
const need = (this.d.sdlc?.config().bands?.security_open_alerts?.window ?? DEFAULT_BAND.window) + EPISODE_LOOKBACK + 2;
|
|
3597
|
+
const scans = config && typeof repo === "string" ? this.d.board.securityScans(repo, config.sources.join(","), need, true).map(scan => ({ value: scan.total!, task: scan.id })) : [];
|
|
3598
|
+
return { hotfix_rate: hotfix, security_open_alerts: scans };
|
|
3599
|
+
}
|
|
3600
|
+
|
|
3601
|
+
/**
|
|
3602
|
+
* #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
|
|
3603
|
+
* 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]`
|
|
3604
|
+
* 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.
|
|
3605
|
+
*/
|
|
3606
|
+
async runSecurityScan(now = Date.now()): Promise<number | undefined> {
|
|
3607
|
+
const config = this.d.sdlc?.config(), scan = config?.securityScan;
|
|
3608
|
+
if (!config || config.mode === "off" || !scan) return undefined;
|
|
3609
|
+
const repo = scan.repo ?? this.d.repository;
|
|
3610
|
+
if (typeof repo !== "string" || !this.nameable(repo)) return undefined;
|
|
3611
|
+
const held = () => !!this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || !!this.d.bus.storageError;
|
|
3612
|
+
if (held()) return undefined;
|
|
3613
|
+
const sources = scan.sources.join(","), last = this.d.board.securityScans(repo, sources, 1).at(-1);
|
|
3614
|
+
if (last && now - last.at < scan.everyH * 3_600_000) return undefined;
|
|
3615
|
+
const read = await readSecurityScan(repo, scan.sources, this.d.sdlc?.gh ?? sdlcGh);
|
|
3616
|
+
const after = this.d.sdlc?.config();
|
|
3617
|
+
// The read awaited: the lifecycle, the schedule or a hold may have changed meanwhile, so nothing is written from a stale decision.
|
|
3618
|
+
if (!read || held() || !after || after.mode === "off" || JSON.stringify(after.securityScan) !== JSON.stringify(scan)) return undefined;
|
|
3619
|
+
const id = this.d.board.recordSecurityScan({ repo, sources, counts: read.counts as Record<string, number | null>, total: read.total });
|
|
3620
|
+
this.proposeFromBands();
|
|
3621
|
+
return id;
|
|
3032
3622
|
}
|
|
3033
3623
|
|
|
3034
3624
|
/**
|
|
@@ -3047,10 +3637,14 @@ export class Tasks {
|
|
|
3047
3637
|
bandsState(): { ready: boolean; signature: string } {
|
|
3048
3638
|
const config = this.d.sdlc?.config();
|
|
3049
3639
|
const configured = !!config && config.mode !== "off" && !!config.bands && Object.keys(config.bands).length > 0;
|
|
3050
|
-
|
|
3640
|
+
// 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.
|
|
3641
|
+
const external = configured ? this.externalSeries() : {};
|
|
3642
|
+
return { ready: configured && !this.d.sweepHeld?.() && !this.d.bus.isRecoveryHeld && !this.d.bus.storageError,
|
|
3643
|
+
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
3644
|
}
|
|
3052
3645
|
|
|
3053
3646
|
proposeFromBands(by: PeerId = HUB): number[] {
|
|
3647
|
+
requireLocalTaskPeer(by);
|
|
3054
3648
|
const config = this.d.sdlc?.config();
|
|
3055
3649
|
if (!config || config.mode === "off" || !config.bands || !Object.keys(config.bands).length) return [];
|
|
3056
3650
|
if (this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) return [];
|
|
@@ -3064,22 +3658,24 @@ export class Tasks {
|
|
|
3064
3658
|
// A card counts as open while its task is not closed: the planner has not yet decided on it.
|
|
3065
3659
|
const open = new Set(bandRows.filter(row => !isClosedTaskState(row.state)).map(row => keyOf(row).split(":")[0]!));
|
|
3066
3660
|
const created: number[] = [];
|
|
3661
|
+
// Fixed words and a number: a release or a security scan is named by its id, a task by `task #id`.
|
|
3662
|
+
const subjectText = (band: BandResult, id: number) => band.subject === "release" ? `release #${id}` : band.subject === "scan" ? `security scan #${id}` : `task #${id}`;
|
|
3067
3663
|
for (const band of report.bands as BandResult[]) {
|
|
3068
3664
|
if (band.tier === "ok" || band.lastTask === null || band.episode === null) continue;
|
|
3069
3665
|
const key = `${band.metric}:#${band.episode}`;
|
|
3070
3666
|
if (band.tier === "log") {
|
|
3071
3667
|
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
|
|
3668
|
+
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
3669
|
continue;
|
|
3074
3670
|
}
|
|
3075
3671
|
if (seen.has(`${key}:propose`) || seen.has(`${key}:diagnose`) || open.has(band.metric)) continue;
|
|
3076
3672
|
const tier = band.tier === "propose" ? "propose" : "diagnose";
|
|
3077
3673
|
const full = `${key}:${tier}`;
|
|
3078
|
-
const evidence = `metric ${band.metric}; newest value ${band.last} on
|
|
3674
|
+
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
3675
|
const diagnose = tier === "diagnose";
|
|
3080
3676
|
const title = diagnose ? `Diagnose SDLC metric ${band.metric}: read-only triage` : `Review SDLC metric ${band.metric}: control band breach`;
|
|
3081
3677
|
const detail = `Band breach key: ${full}\n${evidence}\n${diagnose
|
|
3082
|
-
?
|
|
3678
|
+
? `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
3679
|
: "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
3680
|
const draft = { title, detail, refs: {} };
|
|
3085
3681
|
const signals = this.signalsFor(draft);
|
|
@@ -3091,6 +3687,167 @@ export class Tasks {
|
|
|
3091
3687
|
return created;
|
|
3092
3688
|
}
|
|
3093
3689
|
|
|
3690
|
+
/** 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. */
|
|
3691
|
+
openScheduled(): Map<string, number> {
|
|
3692
|
+
const open = new Map<string, number>();
|
|
3693
|
+
for (const row of this.d.board.list()) {
|
|
3694
|
+
const key = row.history[0]?.by === HUB && !isClosedTaskState(row.state) ? scheduleKeyOf(row.detail) : undefined;
|
|
3695
|
+
if (key !== undefined && !open.has(key)) open.set(key, row.id);
|
|
3696
|
+
}
|
|
3697
|
+
return open;
|
|
3698
|
+
}
|
|
3699
|
+
|
|
3700
|
+
/** Whether a scheduled run may write now: the same holds as every host writer (stopping or recovering, held by the daemon, a storage error). */
|
|
3701
|
+
schedulesHeld(): boolean { return !!this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || !!this.d.bus.storageError; }
|
|
3702
|
+
|
|
3703
|
+
private schedulesRunning = false;
|
|
3704
|
+
/** 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. */
|
|
3705
|
+
schedulesPending = (): number => (this.schedulesRunning ? 1 : 0);
|
|
3706
|
+
/**
|
|
3707
|
+
* 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
|
|
3708
|
+
* 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.
|
|
3709
|
+
*/
|
|
3710
|
+
private async runSchedule(store: ScheduleStore, entry: ScheduleEntry, now: number, extraHold: () => boolean, manual = false): Promise<ScheduleRunResult> {
|
|
3711
|
+
const hold = () => this.schedulesHeld() || extraHold() ? "the hub is held" : undefined;
|
|
3712
|
+
// 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.
|
|
3713
|
+
const skip = () => { if (!manual) store.skipped(entry.id); };
|
|
3714
|
+
if (hold()) { skip(); return { id: entry.id, outcome: "held" }; }
|
|
3715
|
+
const open = this.openScheduled().get(entry.id);
|
|
3716
|
+
if (open !== undefined) { skip(); return { id: entry.id, outcome: "waiting", task: open }; }
|
|
3717
|
+
// 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
|
|
3718
|
+
// 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.
|
|
3719
|
+
// 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,
|
|
3720
|
+
// 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.
|
|
3721
|
+
const admitted = store.epoch(entry.id);
|
|
3722
|
+
let overtaken = false, changed = false;
|
|
3723
|
+
const fence = () => {
|
|
3724
|
+
const reason = hold(); if (reason) overtaken = true;
|
|
3725
|
+
if (!reason && !manual && store.epoch(entry.id) !== admitted) { changed = true; return "the schedule was paused or resumed"; }
|
|
3726
|
+
return reason;
|
|
3727
|
+
};
|
|
3728
|
+
try {
|
|
3729
|
+
const task = await this.propose(HUB, { title: entry.title, detail: scheduledDetail(entry), class: entry.class, backlog: entry.queue === "backlog",
|
|
3730
|
+
...(entry.owner ? { owner: entry.owner } : {}) }, undefined, undefined, fence);
|
|
3731
|
+
store.ran(entry.id, now, task.id);
|
|
3732
|
+
return { id: entry.id, outcome: "ran", task: task.id };
|
|
3733
|
+
} catch (error) {
|
|
3734
|
+
if (changed) return { id: entry.id, outcome: "changed" };
|
|
3735
|
+
if (overtaken) { try { skip(); } catch { /* the store closed with the hub */ } return { id: entry.id, outcome: "held" }; }
|
|
3736
|
+
store.failed(entry.id, sanitize((error as Error).message));
|
|
3737
|
+
return { id: entry.id, outcome: "failed" };
|
|
3738
|
+
}
|
|
3739
|
+
}
|
|
3740
|
+
|
|
3741
|
+
/**
|
|
3742
|
+
* #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).
|
|
3743
|
+
* 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
|
|
3744
|
+
* 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
|
|
3745
|
+
* 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.
|
|
3746
|
+
*/
|
|
3747
|
+
async proposeFromSchedules(store: ScheduleStore, entries: readonly ScheduleEntry[], now: number, extraHold: () => boolean = () => false): Promise<ScheduleRunResult[]> {
|
|
3748
|
+
if (this.schedulesRunning) return [];
|
|
3749
|
+
this.schedulesRunning = true;
|
|
3750
|
+
const out: ScheduleRunResult[] = [];
|
|
3751
|
+
try {
|
|
3752
|
+
store.prune(entries.map(entry => entry.id));
|
|
3753
|
+
for (const entry of entries) {
|
|
3754
|
+
const row = store.ensure(entry.id, now);
|
|
3755
|
+
if (row.paused) continue; // #335: a person's pause is neither a run nor a skipped tick
|
|
3756
|
+
if (now < row.anchor + parseEvery(entry.every)) continue;
|
|
3757
|
+
out.push(await this.runSchedule(store, entry, now, extraHold));
|
|
3758
|
+
}
|
|
3759
|
+
} finally { this.schedulesRunning = false; }
|
|
3760
|
+
return out;
|
|
3761
|
+
}
|
|
3762
|
+
|
|
3763
|
+
/**
|
|
3764
|
+
* #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
|
|
3765
|
+
* 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
|
|
3766
|
+
* runs too: a pause stops the timer, not the person. Only the person may.
|
|
3767
|
+
*/
|
|
3768
|
+
async runScheduleNow(by: PeerId, store: ScheduleStore, entries: readonly ScheduleEntry[], id: unknown, now: number, extraHold: () => boolean = () => false): Promise<ScheduleRunResult & { reason?: string }> {
|
|
3769
|
+
if (!this.personOrConductor(by)) throw new PreEffectToolRefusal("schedule run is a console command");
|
|
3770
|
+
const entry = typeof id === "string" ? entries.find(candidate => candidate.id === id) : undefined;
|
|
3771
|
+
if (!entry) throw new PreEffectToolRefusal(`no schedule ${typeof id === "string" ? JSON.stringify(id.slice(0, 64)) : "named"}; ahub schedule list shows the configured ones`);
|
|
3772
|
+
if (this.schedulesRunning) throw new PreEffectToolRefusal("a schedules pass is running; try again in a moment");
|
|
3773
|
+
this.schedulesRunning = true;
|
|
3774
|
+
try {
|
|
3775
|
+
store.ensure(entry.id, now);
|
|
3776
|
+
// #933: the board-write fence asks the hold again after the PII screen, so a conductor whose role or connection ended meanwhile writes nothing.
|
|
3777
|
+
const result = await this.runSchedule(store, entry, now, () => extraHold() || !this.personOrConductor(by), true);
|
|
3778
|
+
const reason = result.outcome === "failed" ? store.get(entry.id)?.last_error ?? undefined : undefined;
|
|
3779
|
+
return reason ? { ...result, reason } : result;
|
|
3780
|
+
} finally { this.schedulesRunning = false; }
|
|
3781
|
+
}
|
|
3782
|
+
|
|
3783
|
+
/** #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. */
|
|
3784
|
+
setSchedulePaused(by: PeerId, store: ScheduleStore, entries: readonly ScheduleEntry[], id: unknown, paused: boolean, now: number): void {
|
|
3785
|
+
if (!this.personOrConductor(by)) throw new PreEffectToolRefusal(`schedule ${paused ? "pause" : "resume"} is a console command`);
|
|
3786
|
+
const entry = typeof id === "string" ? entries.find(candidate => candidate.id === id) : undefined;
|
|
3787
|
+
if (!entry) throw new PreEffectToolRefusal(`no schedule ${typeof id === "string" ? JSON.stringify(id.slice(0, 64)) : "named"}; ahub schedule list shows the configured ones`);
|
|
3788
|
+
store.setPaused(entry.id, paused, now);
|
|
3789
|
+
}
|
|
3790
|
+
|
|
3791
|
+
/**
|
|
3792
|
+
* #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
|
|
3793
|
+
* 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
|
|
3794
|
+
* 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
|
|
3795
|
+
* 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.
|
|
3796
|
+
* 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
|
|
3797
|
+
* never loses a file). At most MAX_NEW_PER_POLL files per trigger per pass.
|
|
3798
|
+
*/
|
|
3799
|
+
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[]> {
|
|
3800
|
+
if (this.schedulesRunning) return [];
|
|
3801
|
+
this.schedulesRunning = true;
|
|
3802
|
+
const out: FolderRunResult[] = [];
|
|
3803
|
+
try {
|
|
3804
|
+
store.prune(entries.map(entry => entry.id));
|
|
3805
|
+
for (const entry of entries) out.push(await this.runFolderTrigger(store, entry, scan, register, now, extraHold));
|
|
3806
|
+
} finally { this.schedulesRunning = false; }
|
|
3807
|
+
return out;
|
|
3808
|
+
}
|
|
3809
|
+
|
|
3810
|
+
private async runFolderTrigger(store: FolderStorePort, entry: FolderEntry, scanOf: (entry: FolderEntry) => FolderScan, register: (file: ScannedFile) => { id: string; private: boolean }, now: number, extraHold: () => boolean): Promise<FolderRunResult> {
|
|
3811
|
+
const hold = () => this.schedulesHeld() || extraHold() ? "the hub is held" : undefined;
|
|
3812
|
+
if (hold()) return { id: entry.id, outcome: "held", tasks: [], files: 0 };
|
|
3813
|
+
const scan = scanOf(entry);
|
|
3814
|
+
// A refused folder never starts the trigger: when it becomes readable, what is in it then is the baseline, not news.
|
|
3815
|
+
if (scan.refused) { store.polled(entry.id, now, scan.refused); return { id: entry.id, outcome: "refused", tasks: [], files: 0, reason: scan.refused }; }
|
|
3816
|
+
// 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
|
|
3817
|
+
// 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.
|
|
3818
|
+
if (!store.baselined(entry.id)) {
|
|
3819
|
+
store.begin(entry.id, now);
|
|
3820
|
+
for (const file of scan.files) if (!file.seen) store.record(entry.id, file, null, now);
|
|
3821
|
+
if (!scan.truncated) store.markBaselined(entry.id, now); else store.polled(entry.id, now);
|
|
3822
|
+
return { id: entry.id, outcome: "baseline", tasks: [], files: scan.files.length };
|
|
3823
|
+
}
|
|
3824
|
+
// 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
|
|
3825
|
+
// `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.
|
|
3826
|
+
for (const file of scan.files) if (!file.seen && !store.isNew(entry.id, file.sha256)) store.observe(entry.id, file, now);
|
|
3827
|
+
// `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.
|
|
3828
|
+
const fresh = scan.files.filter(file => !file.seen && store.isNew(entry.id, file.sha256));
|
|
3829
|
+
const tasks: number[] = [];
|
|
3830
|
+
let error: string | undefined;
|
|
3831
|
+
for (const file of fresh) {
|
|
3832
|
+
if (tasks.length >= MAX_NEW_PER_POLL || hold()) break;
|
|
3833
|
+
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
|
|
3834
|
+
let overtaken = false;
|
|
3835
|
+
const fence = () => { const reason = hold(); if (reason) overtaken = true; return reason; };
|
|
3836
|
+
try {
|
|
3837
|
+
const source = register(file);
|
|
3838
|
+
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);
|
|
3839
|
+
store.record(entry.id, file, task.id, now);
|
|
3840
|
+
tasks.push(task.id);
|
|
3841
|
+
} catch (caught) {
|
|
3842
|
+
if (overtaken) break;
|
|
3843
|
+
error = sanitize((caught as Error).message);
|
|
3844
|
+
break; // the next poll tries this file again; later files wait behind it so order is kept
|
|
3845
|
+
}
|
|
3846
|
+
}
|
|
3847
|
+
store.polled(entry.id, now, error);
|
|
3848
|
+
return { id: entry.id, outcome: error ? "failed" : tasks.length ? "ran" : "none", tasks, files: fresh.length, ...(scan.truncated ? { more: true as const } : {}), ...(error ? { reason: error } : {}) };
|
|
3849
|
+
}
|
|
3850
|
+
|
|
3094
3851
|
/** S10 read-only planner report; S7 owns rolling bands and automatic board proposals. */
|
|
3095
3852
|
async costReport(by: PeerId, repository?: unknown, scopePrs?: readonly number[]): Promise<Record<string, unknown>> {
|
|
3096
3853
|
const authority = () => {
|
|
@@ -3130,7 +3887,7 @@ export class Tasks {
|
|
|
3130
3887
|
const scoped = current.filter(task => visiblePrs.has(task.sdlc!.pr!) && !this.isPii(task));
|
|
3131
3888
|
const scopedIds = new Set(scoped.map(task => task.id));
|
|
3132
3889
|
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: "
|
|
3890
|
+
return { ...report, stalls, truncated: report.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}), bands: "see ahub sdlc metrics" };
|
|
3134
3891
|
}
|
|
3135
3892
|
|
|
3136
3893
|
async planReviewGap(task: Task): Promise<string | undefined> {
|
|
@@ -3205,18 +3962,21 @@ export class Tasks {
|
|
|
3205
3962
|
* and only then is the rest recorded as before. A receipt with deliverables and nothing else records just the deliverables.
|
|
3206
3963
|
*/
|
|
3207
3964
|
async recordSdlc(by: PeerId, id: unknown, input: unknown): Promise<Task> {
|
|
3965
|
+
const requestCheck = this.requestCheck(by);
|
|
3208
3966
|
if (!input || typeof input !== "object" || Array.isArray(input) || !("deliverables" in input)) return this.recordLifecycle(by, id, input);
|
|
3209
3967
|
const { deliverables, ...receipt } = input as Record<string, unknown>;
|
|
3210
3968
|
const task = this.need(id, false, true);
|
|
3211
3969
|
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
3970
|
const claims = this.deliverableClaims(deliverables, task);
|
|
3213
3971
|
const records = await this.hashClaims(claims, task, true);
|
|
3972
|
+
requestCheck();
|
|
3214
3973
|
const current = this.need(id, false, true);
|
|
3215
3974
|
if (current.history.length !== task.history.length) throw new PreEffectToolRefusal("task changed while its deliverables were hashed; record them again");
|
|
3216
3975
|
return this.recordLifecycle(by, id, receipt, records);
|
|
3217
3976
|
}
|
|
3218
3977
|
|
|
3219
3978
|
recordLifecycle(by: PeerId, id: unknown, input: unknown, deliverables?: DeliverableRecord[]): Task {
|
|
3979
|
+
requireLocalTaskPeer(by);
|
|
3220
3980
|
const task = this.need(id, false, true);
|
|
3221
3981
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
3222
3982
|
const next = sdlcReceipt(input, task.sdlc, config, VERSION);
|
|
@@ -3257,6 +4017,25 @@ export class Tasks {
|
|
|
3257
4017
|
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
4018
|
}
|
|
3259
4019
|
|
|
4020
|
+
/**
|
|
4021
|
+
* 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
|
|
4022
|
+
* 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
|
|
4023
|
+
* 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
|
|
4024
|
+
* open set. Without one (the read failed) every link counts.
|
|
4025
|
+
*/
|
|
4026
|
+
private queueLinks(links: DeliveryTask[], subject: DeliveryTask, queue: MergeQueue | undefined): DeliveryTask[] {
|
|
4027
|
+
if (!queue) return links;
|
|
4028
|
+
const repo = subject.receipt?.repo?.toLowerCase(), open = new Set(queue.rows.map(row => row.pr));
|
|
4029
|
+
// 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.
|
|
4030
|
+
const used = new Set<number>([subject.id, ...subject.after]);
|
|
4031
|
+
for (const link of links) {
|
|
4032
|
+
if (repo === undefined || link.receipt?.repo?.toLowerCase() !== repo || link.receipt.pr === undefined || !open.has(link.receipt.pr)) continue;
|
|
4033
|
+
used.add(link.id);
|
|
4034
|
+
for (const id of link.after) used.add(id);
|
|
4035
|
+
}
|
|
4036
|
+
return links.filter(link => used.has(link.id));
|
|
4037
|
+
}
|
|
4038
|
+
|
|
3260
4039
|
async deliveryQueue(repo: string) {
|
|
3261
4040
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
3262
4041
|
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 +4068,9 @@ export class Tasks {
|
|
|
3289
4068
|
private readonly mergesInFlight = new Set<number>();
|
|
3290
4069
|
/** #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
4070
|
private mergeAuthority(by: PeerId): boolean {
|
|
4071
|
+
if (isRemotePeerId(by)) return false;
|
|
3292
4072
|
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);
|
|
4073
|
+
const holders = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => !isRemotePeerId(peer) && Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
|
|
3294
4074
|
return holders.length === 1 && holders[0] === by;
|
|
3295
4075
|
}
|
|
3296
4076
|
/**
|
|
@@ -3321,14 +4101,15 @@ export class Tasks {
|
|
|
3321
4101
|
this.mergesInFlight.add(task.id);
|
|
3322
4102
|
try {
|
|
3323
4103
|
const links = this.deliveryLinks(config), subject = links.find(link => link.id === task.id)!;
|
|
3324
|
-
const queueIdentity = workflowHash({ config, links });
|
|
3325
4104
|
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
4105
|
recordMerge: record=>this.recordMergeResult(task.id,by,record,artifactCurrent),
|
|
3327
|
-
beforeMerge:
|
|
4106
|
+
beforeMerge: queue => {
|
|
3328
4107
|
if (!this.mergeAuthority(by)) return "merge authority changed during the merge check: nothing was run";
|
|
3329
|
-
//
|
|
4108
|
+
// 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
|
|
4109
|
+
// 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
4110
|
const currentConfig = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
3331
|
-
|
|
4111
|
+
const currentLinks = this.deliveryLinks(currentConfig), currentSubject = currentLinks.find(link => link.id === task.id);
|
|
4112
|
+
return currentSubject && workflowHash({ config: currentConfig, links: this.queueLinks(currentLinks, currentSubject, queue) }) === workflowHash({ config, links: this.queueLinks(links, subject, queue) })
|
|
3332
4113
|
? undefined : "merge queue inputs changed during the merge check: nothing was run";
|
|
3333
4114
|
},
|
|
3334
4115
|
// 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 +4117,7 @@ export class Tasks {
|
|
|
3336
4117
|
fresh: () => {
|
|
3337
4118
|
const now = this.d.board.get(task.id), cfg = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
3338
4119
|
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;
|
|
4120
|
+
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
4121
|
} });
|
|
3341
4122
|
if(mode==="on" && !outcome.ran && "refused" in outcome.decision)this.recordMergeRefusal(task.id,by,outcome.decision.refused);
|
|
3342
4123
|
if(outcome.result?.state==="merged") {
|
|
@@ -3436,6 +4217,12 @@ export class Tasks {
|
|
|
3436
4217
|
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
4218
|
return d;
|
|
3438
4219
|
});
|
|
4220
|
+
// #481 AC12: release interval, lead time from approval to release and hotfix rate, from the shipped releases' recorded history; unknown stays null (never 0).
|
|
4221
|
+
// Releases whose scope holds private work are left out, like the per-release report does.
|
|
4222
|
+
const rowsNow = this.d.board.list();
|
|
4223
|
+
const publicShipped = this.d.board.releases().filter(release => !rowsNow.some(task => task.release === release.id && this.isPii(task)));
|
|
4224
|
+
const metrics = publicShipped.some(release => release.state === "shipped") ? releaseMetrics(publicShipped, rowsNow.filter(task => !this.isPii(task))) : undefined;
|
|
4225
|
+
if (metrics) report.releaseMetrics = metrics;
|
|
3439
4226
|
const trains = this.d.board.releases().filter(release => release.train && ["active", "frozen"].includes(release.state));
|
|
3440
4227
|
if (trains.length) {
|
|
3441
4228
|
const planned = trains.map(release => {
|
|
@@ -3564,11 +4351,12 @@ export class Tasks {
|
|
|
3564
4351
|
const accepted = task.sdlc?.acceptance;
|
|
3565
4352
|
const edit = accepted && !this.isPii(task) && refresh && config.mode !== "off" ? await readSpecEdit(accepted.repo, accepted.issue, read) : undefined;
|
|
3566
4353
|
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;
|
|
4354
|
+
const evalResult = task.sdlc && !this.isPii(task) ? await readEvalFor(config, evidence, task.sdlc.repo, read) : undefined;
|
|
3567
4355
|
const current = valid();
|
|
3568
4356
|
if (this.planIdentity(current) !== this.planIdentity(task) || current.history.length !== task.history.length) throw new PreEffectToolRefusal("task changed during lifecycle readback");
|
|
3569
4357
|
this.capturePlanRead(current, evidence);
|
|
3570
4358
|
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);
|
|
4359
|
+
config.mode !== "off" ? this.planReport(this.d.board.get(current.id)!, evidence) : undefined, integrity, evalResult);
|
|
3572
4360
|
}
|
|
3573
4361
|
|
|
3574
4362
|
/** #481 S1: only build work starts from an accepted spec. */
|
|
@@ -3595,6 +4383,7 @@ export class Tasks {
|
|
|
3595
4383
|
|
|
3596
4384
|
/** Build entry with a live readback of the spec's body edit time (#481 S1): enforce refuses, advise notifies, off does nothing. */
|
|
3597
4385
|
async specGate(by: PeerId, id: unknown): Promise<void> {
|
|
4386
|
+
requireLocalTaskPeer(by);
|
|
3598
4387
|
const task = this.need(id, false, true);
|
|
3599
4388
|
if (!this.specGated(task) || task.owner !== by && by !== USER) return; // accept reports its own refusals
|
|
3600
4389
|
const accepted = task.sdlc?.acceptance;
|
|
@@ -3605,6 +4394,7 @@ export class Tasks {
|
|
|
3605
4394
|
|
|
3606
4395
|
/** #481 S1: a build stage change by an agent re-reads the body edit time first, as accept does. */
|
|
3607
4396
|
async specStageGate(by: PeerId, id: unknown, input: unknown): Promise<void> {
|
|
4397
|
+
requireLocalTaskPeer(by);
|
|
3608
4398
|
const task = this.need(id, false, true), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
3609
4399
|
if (config.mode === "off" || by === USER || !input || typeof input !== "object" || !("stage" in input)) return;
|
|
3610
4400
|
let next: ReturnType<typeof sdlcReceipt>;
|
|
@@ -3640,6 +4430,8 @@ export class Tasks {
|
|
|
3640
4430
|
return { applicability: "unknown", redFirst: "unknown", fileState: "unknown", modifiedTests: [], blockers: [privateTask ? "test integrity: private evidence unavailable" : "test integrity: current evidence unknown"] };
|
|
3641
4431
|
}
|
|
3642
4432
|
|
|
4433
|
+
/** The eval record the last approval-time lifecycle read produced for a task; consumed by the verdict that follows it. */
|
|
4434
|
+
private readonly evalRecords = new Map<number, string>();
|
|
3643
4435
|
private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string, findings?: readonly ReviewFindingSet[]): Promise<PrEvidence | undefined> {
|
|
3644
4436
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
3645
4437
|
if (config.mode === "off") return;
|
|
@@ -3653,16 +4445,24 @@ export class Tasks {
|
|
|
3653
4445
|
};
|
|
3654
4446
|
const evidence = receipt && !this.isPii(task) ? await readSdlcPr(receipt, read, true) : undefined;
|
|
3655
4447
|
const integrity = publicTestIntegrity(receipt && evidence && !this.isPii(task) ? await readTestIntegrity(receipt, evidence, read) : this.unknownTestIntegrity(this.isPii(task)), this.publicMetadataText);
|
|
4448
|
+
const evalResult = receipt && !this.isPii(task) ? await readEvalFor(config, evidence, receipt.repo, read) : undefined;
|
|
3656
4449
|
const current = this.need(task.id, false, true);
|
|
3657
4450
|
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
4451
|
// Advise may continue on the current task, but evidence from its previous link cannot admit that task's review.
|
|
3659
4452
|
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
4453
|
this.capturePlanRead(current, evidence);
|
|
3661
4454
|
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
|
-
|
|
4455
|
+
const status = sdlcStatus(config, receipt, evidence, this.workflowView(current), undefined, this.isPii(current) ? [] : findings ?? this.reviewFindingSets(current), current.refs.commit, planReport, integrity, evalResult);
|
|
4456
|
+
// #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.
|
|
4457
|
+
const evalNames = evalRequiredChecks(config, evidence).filter(name => !config.requiredChecks.includes(name));
|
|
4458
|
+
const evalBlocker = (blocker: string) => evalNames.some(name => blocker.startsWith(`required check ${name}:`)) || evalGapBlockers(status.evalGate).includes(blocker);
|
|
4459
|
+
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
4460
|
if (config.mode === "enforce" && blockers.length) throw new PreEffectToolRefusal(`task #${task.id}: SDLC ${transition} blocked: ${blockers.join("; ")}`);
|
|
3665
|
-
|
|
4461
|
+
// #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.
|
|
4462
|
+
const gate = status.evalGate;
|
|
4463
|
+
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}`);
|
|
4464
|
+
else this.evalRecords.delete(task.id);
|
|
4465
|
+
const advice = [...new Set([...blockers, ...planConformanceBlockers(planReport), ...testIntegrityBlockers(integrity), ...evalAdvice(status.evalGate)])];
|
|
3666
4466
|
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
4467
|
return evidence;
|
|
3668
4468
|
}
|
|
@@ -3746,6 +4546,7 @@ export class Tasks {
|
|
|
3746
4546
|
* announced like one found at assignment (issue #31).
|
|
3747
4547
|
*/
|
|
3748
4548
|
accept(by: PeerId, id: unknown, plan?: unknown, refs?: unknown): Task {
|
|
4549
|
+
requireLocalTaskPeer(by);
|
|
3749
4550
|
const metadata = acceptRefs(refs);
|
|
3750
4551
|
const task = this.need(id, false, true);
|
|
3751
4552
|
this.mine(task, by, "owner", true);
|
|
@@ -3891,9 +4692,10 @@ export class Tasks {
|
|
|
3891
4692
|
* 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
4693
|
* Every path goes through `publicDeliverables`, so a PII task or a path under `pii.paths` shows id, hash and size only.
|
|
3893
4694
|
*/
|
|
3894
|
-
async deliverableList(opts: { task?: number; approvedOnly?: boolean; check?: boolean } = {}): Promise<DeliverableRow[]> {
|
|
4695
|
+
async deliverableList(opts: { task?: number; approvedOnly?: boolean; check?: boolean; visible?: (task: Task) => boolean } = {}): Promise<DeliverableRow[]> {
|
|
3895
4696
|
const rows: DeliverableRow[] = [];
|
|
3896
|
-
|
|
4697
|
+
// #933: `visible` drops a task before anything is read or measured (an agent's list holds only what hub_task_show shows it).
|
|
4698
|
+
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
4699
|
for (const task of tasks) {
|
|
3898
4700
|
const delivered = latestDeliverables(task.history);
|
|
3899
4701
|
if (!delivered) continue;
|
|
@@ -4081,6 +4883,7 @@ export class Tasks {
|
|
|
4081
4883
|
* duplicate notice is the price of never losing one.
|
|
4082
4884
|
*/
|
|
4083
4885
|
replayHeld(peer: PeerId): void {
|
|
4886
|
+
requireLocalTaskPeer(peer);
|
|
4084
4887
|
const finished = this.d.board.list().filter((u) => (u.state === "approved" || u.state === "in_review") && u.owner && !this.isPii(u));
|
|
4085
4888
|
for (const t of this.d.board.list()) {
|
|
4086
4889
|
if (!OPEN.includes(t.state) || t.owner !== peer || this.isPii(t)) continue;
|
|
@@ -4138,6 +4941,19 @@ export class Tasks {
|
|
|
4138
4941
|
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
4942
|
}
|
|
4140
4943
|
|
|
4944
|
+
/**
|
|
4945
|
+
* #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
|
|
4946
|
+
* `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);
|
|
4947
|
+
* a tail without that exact line, or with a number past the bound, records no count: unknown, never zero.
|
|
4948
|
+
*/
|
|
4949
|
+
private checkProvenance(task: Task, tail: string): Pick<HistoryEntry, "checkKey" | "missingKeys"> {
|
|
4950
|
+
let key: string | undefined;
|
|
4951
|
+
try { key = this.d.checkKey?.(task.class, task); } catch { key = undefined; }
|
|
4952
|
+
const lines = tail.split("\n").map(line => line.trim()).filter(Boolean);
|
|
4953
|
+
const match = /^citations: (\d{1,6}) declared keys, (\d{1,6}) missing$/.exec(lines.at(-1) ?? "");
|
|
4954
|
+
return { ...(key ? { checkKey: key } : {}), ...(match ? { missingKeys: Number(match[2]) } : {}) };
|
|
4955
|
+
}
|
|
4956
|
+
|
|
4141
4957
|
private async finishChecked(id: number, by: PeerId, summary: string | undefined, command: string, seen: { events: number; owner: PeerId | null }, withheld: boolean): Promise<void> {
|
|
4142
4958
|
const result = await this.d.runCheck!(command).catch((e: Error) => ({ code: null, timedOut: false, interrupted: false, tail: e.message }));
|
|
4143
4959
|
this.checking.delete(id);
|
|
@@ -4161,12 +4977,12 @@ export class Tasks {
|
|
|
4161
4977
|
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
4978
|
return;
|
|
4163
4979
|
}
|
|
4164
|
-
this.d.board.update(id, HUB, "check passed", {}, `${outcome}\n${result.tail}`.trim());
|
|
4980
|
+
this.d.board.update(id, HUB, "check passed", {}, `${outcome}\n${result.tail}`.trim(), this.checkProvenance(task, result.tail));
|
|
4165
4981
|
// The output goes to the reviewer with the done note, not into shared memory: nobody screened it.
|
|
4166
4982
|
await this.complete(this.d.board.get(id)!, by, `${summary ?? ""}\nCheck: ${outcome}`.trim(), undefined, result.tail, withheld);
|
|
4167
4983
|
return;
|
|
4168
4984
|
}
|
|
4169
|
-
this.d.board.update(id, HUB, "check failed", {}, `${outcome}\n${result.tail}`.trim());
|
|
4985
|
+
this.d.board.update(id, HUB, "check failed", {}, `${outcome}\n${result.tail}`.trim(), this.checkProvenance(task, result.tail));
|
|
4170
4986
|
this.cohorts.withdraw(id); // open again: its done intent no longer counts (issue #107)
|
|
4171
4987
|
if (task.owner) this.d.board.recordOutcome(task.owner, task.class, false);
|
|
4172
4988
|
this.contradict(task);
|
|
@@ -4203,7 +5019,8 @@ export class Tasks {
|
|
|
4203
5019
|
this.warmRepository({ ...task, refs: { ...task.refs, ...completedRefs } });
|
|
4204
5020
|
const noWrites = this.noWritesRecorded({ ...task, refs: { ...task.refs, ...completedRefs } });
|
|
4205
5021
|
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);
|
|
5022
|
+
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);
|
|
5023
|
+
this.queueReviewWritten(next);
|
|
4207
5024
|
this.writePlannerNotices(next, capacity, next.state === "approved");
|
|
4208
5025
|
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
5026
|
this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
|
|
@@ -4323,7 +5140,7 @@ export class Tasks {
|
|
|
4323
5140
|
let quietDrift = false;
|
|
4324
5141
|
const guard = () => {
|
|
4325
5142
|
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);
|
|
5143
|
+
const holders = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => !isRemotePeerId(peer) && Array.isArray(roles) && roles.includes("conductor")).map(([peer]) => peer);
|
|
4327
5144
|
if (holders.length !== 1 || holders[0] !== actor) throw new (preEffect ? PreEffectToolRefusal : Error)("review admission authority changed");
|
|
4328
5145
|
}
|
|
4329
5146
|
// #694: the board copy seen through the same staged identity link the caller's task carries.
|
|
@@ -4532,6 +5349,7 @@ export class Tasks {
|
|
|
4532
5349
|
|
|
4533
5350
|
/** Host-only admission for a queued review after asynchronous native preparation. */
|
|
4534
5351
|
reviewBindingCurrent(by: PeerId, id: number, binding: { head?: string; flowRevision?: number }): boolean {
|
|
5352
|
+
requireLocalTaskPeer(by);
|
|
4535
5353
|
const task = this.d.board.get(id);
|
|
4536
5354
|
return !!task && task.state === "in_review" && task.reviewer === by && !this.isPii(task)
|
|
4537
5355
|
&& this.reviewRequestRevision(task) === binding.flowRevision && this.reviewHead(task) === binding.head;
|
|
@@ -4539,6 +5357,7 @@ export class Tasks {
|
|
|
4539
5357
|
|
|
4540
5358
|
/** Host-only failure report, bound to the exact review request and its still-current child generation. */
|
|
4541
5359
|
async retryReviewer(id: number, failedPeer: PeerId, failure: { head?: string; flowRevision?: number; cause: "start_failed" | "quota" | "no_verdict" }, current: () => boolean, onReoffered?: () => void): Promise<Task> {
|
|
5360
|
+
requireLocalTaskPeer(failedPeer);
|
|
4542
5361
|
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
5362
|
const valid = () => {
|
|
4544
5363
|
const task = this.need(id, false, true);
|
|
@@ -4557,7 +5376,8 @@ export class Tasks {
|
|
|
4557
5376
|
valid();
|
|
4558
5377
|
const note = manual ? "named reviewer recorded no verdict; the person must review"
|
|
4559
5378
|
: 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);
|
|
5379
|
+
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);
|
|
5380
|
+
this.queueReviewWritten(next);
|
|
4561
5381
|
this.releaseNotice(`task ${this.publicTitle(next)}: former review request withdrawal incomplete`, () => this.withdrawRoleRequest(task, next, "reviewer"));
|
|
4562
5382
|
let offered = false;
|
|
4563
5383
|
if (reviewer === USER) {
|
|
@@ -4637,6 +5457,7 @@ export class Tasks {
|
|
|
4637
5457
|
}
|
|
4638
5458
|
|
|
4639
5459
|
async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown, head?: unknown, coverage?: unknown, findings?: unknown): Promise<Task> {
|
|
5460
|
+
const requestCheck = this.requestCheck(by);
|
|
4640
5461
|
const authority = by === USER ? undefined : this.d.reviewAuthority?.(by, Number(id));
|
|
4641
5462
|
let findingGuard: ((task: Task) => void) | undefined;
|
|
4642
5463
|
// #520: what the coverage was checked against, pinned through every await: the commit and the lifecycle receipt (the
|
|
@@ -4644,6 +5465,7 @@ export class Tasks {
|
|
|
4644
5465
|
let covered: { commit: string | undefined; receipt: string } | undefined;
|
|
4645
5466
|
let conformanceIdentity: string | undefined;
|
|
4646
5467
|
const valid = (): Task => {
|
|
5468
|
+
requestCheck();
|
|
4647
5469
|
const refused = authority?.();
|
|
4648
5470
|
if (refused) throw new PreEffectToolRefusal(refused); // rechecked after every await, right before each write
|
|
4649
5471
|
const t = this.need(id, false, true);
|
|
@@ -4749,7 +5571,12 @@ export class Tasks {
|
|
|
4749
5571
|
}
|
|
4750
5572
|
}
|
|
4751
5573
|
}
|
|
4752
|
-
if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") {
|
|
5574
|
+
if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") {
|
|
5575
|
+
this.evalRecords.delete(task.id);
|
|
5576
|
+
await this.lifecycleGate(task, "approval", reviewedHead, proposedSets(task)); task = valid();
|
|
5577
|
+
const record = this.evalRecords.get(task.id); this.evalRecords.delete(task.id);
|
|
5578
|
+
if (record) note = `${note ?? ""}\n${record}`.trim();
|
|
5579
|
+
}
|
|
4753
5580
|
let candidate: Extract<CandidateInput, { kind: "fail" }> | undefined;
|
|
4754
5581
|
if (verdict === "changes_requested" && !evidenceOnly && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
|
|
4755
5582
|
const input: CandidateInput = { project: this.d.store?.project ?? this.d.project, kind: "fail", approach: this.publicTitle(task), symptom: items.join("\n"),
|
|
@@ -4792,12 +5619,14 @@ export class Tasks {
|
|
|
4792
5619
|
this.autoMerge(next, this.mirrorVerdict(next, by, "approved", reviewedHead ?? next.sdlc?.reviewedHead, note, withheld));
|
|
4793
5620
|
// A proposal storage failure cannot roll back the saved verdict; another matching verdict retries it.
|
|
4794
5621
|
this.releaseNotice(`task #${next.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(next, by, parsed, withheld));
|
|
5622
|
+
this.releaseNotice(`task #${next.id}: review saved but recurring-criterion proposal unavailable`, () => this.proposeCriterionCandidates(next, by, withheld));
|
|
4795
5623
|
return next;
|
|
4796
5624
|
}
|
|
4797
5625
|
this.ciWatchers.cancel(task.id); this.heldReviews.delete(task.id);
|
|
4798
5626
|
const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: evidenceOnly ? task.rejections : task.rejections + 1 }, note, flag);
|
|
4799
5627
|
void this.mirrorVerdict(rejected, by, "changes_requested", reviewedHead ?? rejected.sdlc?.reviewedHead, note, withheld);
|
|
4800
5628
|
if (!evidenceOnly) this.releaseNotice(`task #${rejected.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(rejected, by, parsed, withheld));
|
|
5629
|
+
if (!evidenceOnly) this.releaseNotice(`task #${rejected.id}: review saved but recurring-criterion proposal unavailable`, () => this.proposeCriterionCandidates(rejected, by, withheld));
|
|
4801
5630
|
if (candidate && !pii && !this.d.privateTurn?.(by) && this.candidateTextAllowed(JSON.stringify({ candidate, refs: rejected.refs, plan: rejected.plan }), rejected)) {
|
|
4802
5631
|
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
5632
|
}
|
|
@@ -4837,7 +5666,7 @@ export class Tasks {
|
|
|
4837
5666
|
* 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
5667
|
*/
|
|
4839
5668
|
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);
|
|
5669
|
+
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
5670
|
const text = evidenceOnlyNotice(task.id, reviewer);
|
|
4842
5671
|
if (!conductors.length) { this.releaseNotice(text); return; }
|
|
4843
5672
|
this.releaseNotice(`task #${task.id}: could not tell the conductor a code-scope review was evidence-only`, () => {
|
|
@@ -4847,6 +5676,7 @@ export class Tasks {
|
|
|
4847
5676
|
|
|
4848
5677
|
/** Task-level escalation: the next attached peer in the class's escalate_to takes over, with the history. */
|
|
4849
5678
|
async escalate(by: PeerId, id: unknown, why = "by hand", reason: TaskMoveReason | undefined = by === HUB ? undefined : "manual", preEffect = false): Promise<Task> {
|
|
5679
|
+
requireLocalTaskPeer(by);
|
|
4850
5680
|
let task = this.need(id, true, preEffect);
|
|
4851
5681
|
const list = this.d.routing().classes[task.class]?.escalate_to ?? [];
|
|
4852
5682
|
if (task.state === "changes_requested") task = this.d.board.update(task.id, HUB, "reopened", { state: "in_progress" });
|
|
@@ -4908,8 +5738,33 @@ export class Tasks {
|
|
|
4908
5738
|
});
|
|
4909
5739
|
}
|
|
4910
5740
|
|
|
5741
|
+
/** Preserve explicit intent across a policy reload; recheck conductor authority after every await. */
|
|
5742
|
+
private explicitQueueAssignment(by: PeerId, role: Exclude<TaskAssignmentRole, "planner">, preEffect: boolean): { role: "owner" | "reviewer"; current: () => void } | undefined {
|
|
5743
|
+
if (by === USER) return { role, current: () => {} };
|
|
5744
|
+
const current = () => {
|
|
5745
|
+
const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([peer]) => peer);
|
|
5746
|
+
return holders.length === 1 && holders[0] === by && this.d.bus.isRostered(by);
|
|
5747
|
+
};
|
|
5748
|
+
if (!current()) return undefined;
|
|
5749
|
+
return { role, current: () => { if (!current()) throw new (preEffect ? PreEffectToolRefusal : Error)("only the current configured conductor may override assignment admission"); } };
|
|
5750
|
+
}
|
|
5751
|
+
|
|
5752
|
+
private configuredManualConductor(by: PeerId): boolean {
|
|
5753
|
+
if (by === USER || by === HUB || isRemotePeerId(by)) return false;
|
|
5754
|
+
const holders = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => !isRemotePeerId(peer) && Array.isArray(roles) && roles.includes("conductor")).map(([peer]) => peer);
|
|
5755
|
+
return holders.length === 1 && holders[0] === by;
|
|
5756
|
+
}
|
|
5757
|
+
private conductorManualCheck(by: PeerId, preEffect: boolean): () => void {
|
|
5758
|
+
return () => {
|
|
5759
|
+
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");
|
|
5760
|
+
};
|
|
5761
|
+
}
|
|
5762
|
+
|
|
4911
5763
|
/** Caller authority is checked by the daemon; keep the real actor in task history. */
|
|
4912
5764
|
async assignTo(id: unknown, peer: PeerId, by: PeerId = USER, preEffect = false, role: TaskAssignmentRole = "owner", expectedFlowRevision?: number): Promise<Task> {
|
|
5765
|
+
requireLocalTaskPeer(by); requireLocalTaskPeer(peer);
|
|
5766
|
+
const conductorManual = this.configuredManualConductor(by), manualCurrent = conductorManual ? this.conductorManualCheck(by, preEffect) : undefined;
|
|
5767
|
+
manualCurrent?.();
|
|
4913
5768
|
if (role !== "owner" && role !== "reviewer" && role !== "planner") throw new (preEffect ? PreEffectToolRefusal : Error)("role must be owner, reviewer or planner");
|
|
4914
5769
|
if (role === "planner") {
|
|
4915
5770
|
if (by !== USER) throw new PreEffectToolRefusal("only the person may name a task planner");
|
|
@@ -4923,9 +5778,11 @@ export class Tasks {
|
|
|
4923
5778
|
return next;
|
|
4924
5779
|
}
|
|
4925
5780
|
if (role === "reviewer" && by !== USER) {
|
|
4926
|
-
const conductors = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
|
|
5781
|
+
const conductors = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => !isRemotePeerId(peer) && Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
|
|
4927
5782
|
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
5783
|
}
|
|
5784
|
+
const queueAssignment = this.explicitQueueAssignment(by, role, preEffect);
|
|
5785
|
+
queueAssignment?.current();
|
|
4929
5786
|
const task = this.need(id, role === "owner", preEffect);
|
|
4930
5787
|
this.checkFlowRevision(task, expectedFlowRevision, preEffect);
|
|
4931
5788
|
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 +5790,23 @@ export class Tasks {
|
|
|
4933
5790
|
if (role === "reviewer") {
|
|
4934
5791
|
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
5792
|
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) });
|
|
5793
|
+
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
5794
|
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
5795
|
const admission = task.state === "in_review" ? await this.reviewRequestAdmission(task, undefined, preEffect, by) : undefined;
|
|
4939
5796
|
// Every runtime authority check precedes the final write, including revocations during the metadata await.
|
|
5797
|
+
manualCurrent?.();
|
|
5798
|
+
queueAssignment?.current();
|
|
4940
5799
|
const current = this.need(task.id, false, preEffect);
|
|
4941
5800
|
if (current.history.length !== task.history.length) throw new (preEffect ? PreEffectToolRefusal : Error)("task changed during reviewer admission");
|
|
4942
5801
|
if (by !== USER) {
|
|
4943
|
-
const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
|
|
5802
|
+
const holders = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => !isRemotePeerId(peer) && Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
|
|
4944
5803
|
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
5804
|
}
|
|
4946
5805
|
if (!this.d.bus.isRostered(peer)) throw new (preEffect ? PreEffectToolRefusal : Error)(`${peer} is off roster`);
|
|
4947
5806
|
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) });
|
|
5807
|
+
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
5808
|
if (eligible.reviewer !== peer) throw new (preEffect ? PreEffectToolRefusal : Error)("reviewer is no longer eligible");
|
|
5809
|
+
queueAssignment?.current();
|
|
4950
5810
|
const next = this.d.board.update(task.id, by, "reviewer changed", { reviewer: peer }, this.assignmentNote(`named reviewer ${peer}`, a), { reason: "manual" }, expectedFlowRevision);
|
|
4951
5811
|
if (next.state === "in_review" && task.reviewer && task.reviewer !== peer) {
|
|
4952
5812
|
this.withdrawRoleRequest(task, next, "reviewer");
|
|
@@ -4955,15 +5815,23 @@ export class Tasks {
|
|
|
4955
5815
|
return next;
|
|
4956
5816
|
}
|
|
4957
5817
|
// 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
|
-
|
|
5818
|
+
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(); } });
|
|
5819
|
+
manualCurrent?.();
|
|
5820
|
+
queueAssignment?.current();
|
|
5821
|
+
const next = this.d.board.update(task.id, by, "reserved", { reserved: peer }, `for ${peer}`, { reason: "manual", ...(conductorManual ? { conductorManual: true as const } : {}) }, expectedFlowRevision);
|
|
4960
5822
|
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
5823
|
return next;
|
|
4962
5824
|
}
|
|
4963
5825
|
|
|
4964
|
-
/**
|
|
5826
|
+
/** Explicit removal without routing a replacement; host history preserves manual intent across role changes. */
|
|
4965
5827
|
async unassign(id: unknown, by: PeerId = USER, preEffect = false, role: TaskAssignmentRole = "owner", expectedFlowRevision?: number): Promise<Task> {
|
|
4966
|
-
|
|
5828
|
+
const authority = by === USER ? undefined : this.conductorManualCheck(by, preEffect);
|
|
5829
|
+
const current = authority && (() => {
|
|
5830
|
+
authority();
|
|
5831
|
+
if (this.d.privateTurn?.(by)) throw new (preEffect ? PreEffectToolRefusal : Error)("conductor task mutations are unavailable during a private turn");
|
|
5832
|
+
if (this.d.cancellationHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) throw new (preEffect ? PreEffectToolRefusal : Error)("recovery or shutdown is holding task writes");
|
|
5833
|
+
});
|
|
5834
|
+
current?.();
|
|
4967
5835
|
if (role !== "owner" && role !== "reviewer") throw new (preEffect ? PreEffectToolRefusal : Error)("role must be owner or reviewer");
|
|
4968
5836
|
const task = this.need(id, false, preEffect);
|
|
4969
5837
|
this.checkFlowRevision(task, expectedFlowRevision, preEffect);
|
|
@@ -4971,7 +5839,8 @@ export class Tasks {
|
|
|
4971
5839
|
if (role === "owner") {
|
|
4972
5840
|
if (!OPEN.includes(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its owner cannot be unassigned`);
|
|
4973
5841
|
if (!task.owner && !task.reserved) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: no owner or owner reservation to unassign`);
|
|
4974
|
-
|
|
5842
|
+
current?.();
|
|
5843
|
+
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
5844
|
this.cohorts.leave(task.id);
|
|
4976
5845
|
this.withdrawRoleRequest(task, next, "owner");
|
|
4977
5846
|
return next;
|
|
@@ -4979,7 +5848,8 @@ export class Tasks {
|
|
|
4979
5848
|
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
5849
|
if (!task.reviewer) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: no reviewer to unassign`);
|
|
4981
5850
|
if (task.reviewer === USER && this.isPii(task)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: the human reviewer is required for PII work`);
|
|
4982
|
-
|
|
5851
|
+
current?.();
|
|
5852
|
+
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
5853
|
this.withdrawRoleRequest(task, next, "reviewer");
|
|
4984
5854
|
return next;
|
|
4985
5855
|
}
|
|
@@ -4989,11 +5859,13 @@ export class Tasks {
|
|
|
4989
5859
|
* the usual constraints. Tasks it was reviewing get another reviewer. Moves are reported, never taken back automatically.
|
|
4990
5860
|
*/
|
|
4991
5861
|
async reassignForPause(peer: PeerId, context: string | undefined, urgentOnly = false): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
|
|
5862
|
+
requireLocalTaskPeer(peer);
|
|
4992
5863
|
return this.reassignWork(peer, context, urgentOnly, false);
|
|
4993
5864
|
}
|
|
4994
5865
|
|
|
4995
5866
|
/** Read-only preview using the same assignment constraints as the roster relay. */
|
|
4996
5867
|
rosterPreview(peer: PeerId): { id: number; title: string; role: "owner" | "reviewer"; to: PeerId | null }[] {
|
|
5868
|
+
requireLocalTaskPeer(peer);
|
|
4997
5869
|
const routing = this.d.routing();
|
|
4998
5870
|
return this.d.board.list().filter(task => !isClosedTaskState(task.state) && (task.owner === peer || task.reviewer === peer)).map(task => {
|
|
4999
5871
|
const owner = task.owner === peer;
|
|
@@ -5007,6 +5879,7 @@ export class Tasks {
|
|
|
5007
5879
|
|
|
5008
5880
|
/** A roster removal offers work through the quota relay's constraints, preserving assignments nobody can take. */
|
|
5009
5881
|
async reassignForRoster(peer: PeerId, current?: () => void): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
|
|
5882
|
+
requireLocalTaskPeer(peer);
|
|
5010
5883
|
return this.reassignWork(peer, undefined, false, true, current);
|
|
5011
5884
|
}
|
|
5012
5885
|
|
|
@@ -5055,7 +5928,8 @@ export class Tasks {
|
|
|
5055
5928
|
continue;
|
|
5056
5929
|
}
|
|
5057
5930
|
current?.();
|
|
5058
|
-
const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, this.assignmentNote(why, a), this.reviewMetadata(a));
|
|
5931
|
+
const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, this.assignmentNote(why, a), this.reviewMetadata(a, task));
|
|
5932
|
+
this.queueReviewWritten(next);
|
|
5059
5933
|
moved.push({ id: task.id, title: this.publicTitle(task), to: reviewer, role: "reviewer" });
|
|
5060
5934
|
current?.();
|
|
5061
5935
|
if (next.state === "in_review" && reviewer && reviewer !== USER) {
|
|
@@ -5090,11 +5964,13 @@ export class Tasks {
|
|
|
5090
5964
|
|
|
5091
5965
|
/** Candidate only: the person remains the sole acceptance authority. */
|
|
5092
5966
|
async correct(by: PeerId, input: unknown): Promise<string> {
|
|
5967
|
+
const requestCheck = this.requestCheck(by);
|
|
5093
5968
|
const args = correctionInput(input);
|
|
5094
5969
|
const st = this.d.store;
|
|
5095
5970
|
if (!st || !this.d.candidates) throw new PreEffectToolRefusal("memory correction unavailable");
|
|
5096
5971
|
const screen: MemoryScreen = rows => this.d.candidateScreen?.(rows) ?? withoutPii(rows, this.d.routing(), this.d.cwd, this.d.piiPaths ?? []);
|
|
5097
5972
|
const valid = () => {
|
|
5973
|
+
requestCheck();
|
|
5098
5974
|
this.d.memoryCheck?.();
|
|
5099
5975
|
if (this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("memory correction unavailable during a private turn");
|
|
5100
5976
|
if (!this.candidateTextAllowed(JSON.stringify(args))) throw new PreEffectToolRefusal("memory correction withheld by privacy policy");
|
|
@@ -5169,6 +6045,7 @@ export class Tasks {
|
|
|
5169
6045
|
}
|
|
5170
6046
|
/** A person links a board task of this project to a long-term task (`ahub goal link`). */
|
|
5171
6047
|
linkGoal(by: PeerId, id: unknown, ref: unknown): string {
|
|
6048
|
+
requireLocalTaskPeer(by);
|
|
5172
6049
|
const task = this.d.board.get(Number(id));
|
|
5173
6050
|
if (!task) throw new Error(`no task #${String(id).slice(0, 12)}`);
|
|
5174
6051
|
const goal = this.goalFor(by, ref);
|