@staix/agent-hub 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +70 -3
- package/README.md +2 -2
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +10 -5
- 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 +20 -7
- package/docs/agent-notes/local-worker.md +5 -0
- package/docs/agent-notes/models.md +2 -0
- package/docs/agent-notes/tasks.md +20 -10
- package/docs/agent-notes/tests.md +13 -5
- package/docs/bench.md +1 -1
- package/docs/data-policy.md +2 -0
- package/docs/dogfood.md +41 -5
- package/docs/events.md +2 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +598 -63
- package/docs/profiles.md +13 -1
- package/docs/quickstart.md +18 -9
- package/docs/research.md +7 -4
- package/docs/sdlc.md +66 -10
- package/docs/security.md +397 -35
- package/docs/smoke.md +127 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +1149 -19
- package/docs/verification/2026-10-27-0.19.0.md +5 -5
- package/docs/verification/2026-10-27-0.20.0.md +75 -0
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
- package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
- package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
- package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
- package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
- package/docs/verification/2026-10-29-0.21.0.md +61 -0
- package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
- package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
- package/docs/verification/claude-implementer-permission-triage.ts +160 -0
- package/docs/verification/claude-permission-transport-native.py +71 -14
- package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
- package/docs/verified.json +26 -18
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1116 -284
- package/src/adapters/acp.ts +87 -3
- package/src/adapters/channel-peer.ts +11 -5
- package/src/adapters/claude-channel.ts +13 -6
- package/src/adapters/claude-implementer.ts +61 -7
- package/src/adapters/codex-appserver.ts +88 -5
- package/src/adapters/conformance/enrollment.ts +4 -4
- package/src/adapters/conformance/factories.ts +2 -1
- package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
- package/src/adapters/conformance/kit.ts +10 -6
- 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 +42 -25
- package/src/adapters/native-proof-ports.ts +10 -0
- package/src/adapters/pi.ts +7 -0
- package/src/adapters/registry-types.ts +4 -0
- package/src/adapters/reviewer-print.ts +1 -0
- package/src/broker/protocol.ts +203 -0
- package/src/broker/server.ts +314 -0
- package/src/broker/store.ts +548 -0
- package/src/cli/bench.ts +3 -2
- package/src/cli/broker.ts +58 -0
- package/src/cli/conductor-recovery.ts +142 -0
- package/src/cli/configured-acp-entry.ts +26 -6
- package/src/cli/console-state.ts +59 -17
- package/src/cli/console.ts +17 -2
- package/src/cli/facts-hook.ts +3 -1
- package/src/cli/help.ts +28 -16
- package/src/cli/identity.ts +23 -10
- package/src/cli/init.ts +34 -7
- package/src/cli/intake-output.ts +33 -0
- package/src/cli/main.ts +314 -71
- package/src/cli/output.ts +41 -1
- package/src/cli/recovery-package.ts +18 -1
- package/src/cli/role-holders.ts +35 -0
- package/src/cli/room.ts +304 -0
- package/src/cli/say-input.ts +16 -0
- package/src/cli/schedule-output.ts +49 -0
- package/src/cli/sdlc-output.ts +44 -6
- package/src/cli/smoke-pty-bridge.ts +33 -0
- package/src/cli/smoke-pty.ts +145 -0
- package/src/cli/smoke-run.ts +139 -0
- package/src/cli/smoke-target.ts +130 -0
- package/src/cli/source-output.ts +29 -0
- package/src/cli/task-output.ts +3 -2
- package/src/cli/upgrade-runtime.ts +98 -16
- package/src/cli/upgrade.ts +66 -7
- package/src/hub/ask.ts +76 -12
- package/src/hub/board.ts +132 -22
- package/src/hub/bus.ts +399 -56
- package/src/hub/child-process.ts +2 -0
- package/src/hub/ci-watch.ts +162 -0
- package/src/hub/claude-failure-stage.ts +85 -0
- package/src/hub/claude-launch-proof.ts +67 -2
- package/src/hub/claude-shell-wrapper.ts +11 -5
- package/src/hub/conductor.ts +17 -4
- package/src/hub/config-apply.ts +32 -5
- package/src/hub/config-reload.ts +7 -5
- package/src/hub/config-trust.ts +8 -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 +1228 -242
- package/src/hub/deliverable-files.ts +8 -2
- package/src/hub/delivery-journal.ts +123 -17
- package/src/hub/dogfood-profile.ts +32 -5
- package/src/hub/dogfood-read.ts +6 -2
- package/src/hub/envelope.ts +11 -0
- package/src/hub/eval-gate.ts +131 -0
- package/src/hub/events.ts +1 -1
- package/src/hub/external-instance.ts +69 -0
- package/src/hub/facts.ts +15 -5
- package/src/hub/folder-trigger.ts +223 -0
- package/src/hub/hub-tools.ts +2 -1
- package/src/hub/inference.ts +20 -5
- package/src/hub/instance-config.ts +69 -3
- package/src/hub/instance-execution.ts +6 -2
- package/src/hub/instances.ts +32 -4
- package/src/hub/intake.ts +125 -0
- package/src/hub/learning.ts +85 -0
- package/src/hub/lifecycle.ts +7 -3
- package/src/hub/limits.ts +12 -5
- package/src/hub/native-permission-request.ts +77 -0
- package/src/hub/operational-authority.ts +63 -5
- package/src/hub/operational-settings.ts +76 -0
- package/src/hub/operational-view.ts +1 -1
- package/src/hub/packs.ts +132 -0
- package/src/hub/path-policy.ts +7 -0
- package/src/hub/peer-doing.ts +130 -0
- package/src/hub/peers.ts +46 -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 +7 -1
- package/src/hub/shipped-packs.ts +8 -0
- package/src/hub/snapshots.ts +72 -6
- package/src/hub/source-read.ts +160 -0
- package/src/hub/sources.ts +311 -0
- package/src/hub/task-shell-default.ts +3 -2
- package/src/hub/tasks.ts +1047 -115
- package/src/hub/workflow.ts +1 -1
- package/src/local/deny.ts +21 -0
- package/src/local/doc-read.ts +69 -0
- package/src/local/proxy.ts +4 -13
- package/src/local/sandbox.ts +3 -1
- package/src/local/tools.ts +37 -3
- package/src/local/web-fetch-config.ts +43 -0
- package/src/local/web-fetch.ts +183 -0
- package/src/memory/brief.ts +85 -13
- package/src/memory/capture.ts +1 -1
- package/src/rooms/application.ts +114 -0
- package/src/rooms/boundary.ts +103 -0
- package/src/rooms/commands.ts +294 -0
- package/src/rooms/crypto.ts +371 -0
- package/src/rooms/link.ts +584 -0
- package/src/rooms/policy.ts +82 -0
- package/src/rooms/remote-peer.ts +80 -0
- package/src/rooms/service.ts +426 -0
- package/src/rooms/status.ts +146 -0
- package/src/rooms/store.ts +1210 -0
- package/src/rooms/wire.ts +465 -0
- package/src/ui/index.html +91 -4
- package/templates/packs/example.pack.json +13 -0
package/src/hub/tasks.ts
CHANGED
|
@@ -1,21 +1,28 @@
|
|
|
1
|
+
import type { ContextAdmission } from "./context-window.ts";
|
|
2
|
+
import { queueBackpressure, queueBackpressurePolicy, type QueueBackpressureCode, type QueueBackpressurePolicy, type QueueBackpressureStatus, type QueuePressure } from "./queue-backpressure.ts";
|
|
1
3
|
import { canonicalMemorySymbolNode, normalizeMemorySymbolInput, MEMORY_SYMBOL_LIMIT, type MemorySymbolNodes } from "./memory-symbols.ts";
|
|
2
4
|
import { createHash } from "node:crypto";
|
|
3
5
|
import { taskShellBindingValid, type TaskShellBinding } from "./task-shell-command.ts";
|
|
4
6
|
import { buildTaskCodeSymbols, publicTaskCodeSymbols, taskCodeSymbolsDisjoint, HOST_CODE_SYMBOLS } from "./task-code-symbols.ts";
|
|
5
7
|
import { normalizePlanSymbol, symbolsOverlap } from "./symbol-normalizer.ts";
|
|
6
8
|
import { publicTestIntegrity, readTestIntegrity, testIntegrityBlockers, type TestIntegrity } from "./test-integrity.ts";
|
|
9
|
+
import { evalAdvice, evalGapBlockers, evalRequiredChecks, readEvalFor } from "./eval-gate.ts";
|
|
7
10
|
import { completePlanInventory, planConformance, planConformanceBlockers, planFingerprint, type PlanConformance } from "./plan-conformance.ts";
|
|
8
11
|
import { RELEASE_TRAIN_HISTORY_LIMIT, releaseTrainData, releaseTrainReadiness, releaseMetrics } from "./sdlc-release.ts";
|
|
9
12
|
import { cleanCostPlan, costMetrics, type CostPlan, type ReviewCost } from "./cost-governance.ts";
|
|
10
13
|
import { qualifiedTitleRepos, siblingTitleRefs, specRefs } from "./spec-read.ts";
|
|
11
|
-
import {
|
|
14
|
+
import { folderDetail, MAX_NEW_PER_POLL, parseEvery, SOURCE_ID, scheduledDetail, scheduleKeyOf, type FolderEntry, type FolderRunResult, type FolderScan, type FolderStorePort, type ScannedFile, type ScheduleEntry, type ScheduleRunResult, type ScheduleStore } from "./schedules.ts";
|
|
15
|
+
import { INTAKE_MAX_SOURCES, INTAKE_MAX_TEXT, intakeDetail, intakeSystemPrompt, intakeUserMessage, parseIntakeDetail, parseIntakeSplit, plannerRequestBody } from "./intake.ts";
|
|
16
|
+
import { DEFAULT_BAND, EPISODE_LOOKBACK, sdlcMetrics, type BandMetric, type BandResult, type MetricTask, type MetricsReport, type SeriesPoint } from "./sdlc-metrics.ts";
|
|
17
|
+
import { readSecurityScan } from "./security-baseline.ts";
|
|
18
|
+
import { cleanMergeCost } from "./cost-governance.ts";
|
|
12
19
|
import type { InstancesConfig } from "./instances.ts";
|
|
13
20
|
import { composeRememberText } from "./memory-fail.ts";
|
|
14
21
|
import { VERSION } from "../version.ts";
|
|
15
22
|
import { NoWriteTurns } from "./no-writes.ts";
|
|
16
23
|
import { readGitHubImport, type GitHubSource, type GitHubImportSelection } from "./github-import.ts";
|
|
17
24
|
import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS, actualTaskDates, planningPatch, type PlanningFields, type Release, type ReleaseState } from "./planning.ts";
|
|
18
|
-
import { readMergeQueue, type DeliveryTask } from "./sdlc-queue.ts";
|
|
25
|
+
import { readMergeQueue, type DeliveryTask, type MergeQueue } from "./sdlc-queue.ts";
|
|
19
26
|
import { guardedMerge, mergeModeFor, mergeView, type MergeView } from "./sdlc-merge.ts";
|
|
20
27
|
import { mirrorVerdict as mirrorToGitHub, type MirrorVerdict } from "./sdlc-mirror.ts";
|
|
21
28
|
import { CODE_SCOPE_LINE, evidenceOnlyNotice, type ReviewScope } from "./review-scope.ts";
|
|
@@ -28,17 +35,18 @@ import type { MemoryScreen } from "./read-tools.ts";
|
|
|
28
35
|
import { withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
|
|
29
36
|
import { profileClass, profileData, profileTaskLabel, projectProfile, type ProfileName, type ProfileSelection, type ProjectProfile } from "./profiles.ts";
|
|
30
37
|
import { criteriaView, unmetCriteriaIds, workflowHash } from "./workflow.ts";
|
|
38
|
+
import { candidateCard, criterionKey, groupUnmetCriteria, LEARNING_DEFAULTS, selectGroups, type CriterionGroup } from "./learning.ts";
|
|
31
39
|
import { approvedDeliverables, latestDeliverables, parseDeliverableClaims, publicDeliverables, type DeliverableRecord, type DeliverableStatus } from "./deliverables.ts";
|
|
32
40
|
import { checkDeliverables, DeliverableRefusal, hashDeliverables, vetDeliverables, type DeliverablePorts } from "./deliverable-files.ts";
|
|
33
41
|
import { isAbsolute, relative } from "node:path";
|
|
34
42
|
import type { Briefs, NativeItem } from "../memory/brief.ts";
|
|
35
43
|
import type { MemoryClient } from "../memory/client.ts";
|
|
36
44
|
import { candidateTargetRevision, type CandidateInput, type CandidateStore, extractNodes, looksSecret, PROMOTED_KINDS, taskKey, type GoalView, type Kind, type MemoryStore, type Scope, type Tier } from "@staix/agent-mem";
|
|
37
|
-
import { CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskCanPlan, taskHasWorkEvidence, taskHasStarted, planningUndoEntry, isClosedTaskState, type TaskStall, type HistoryEntry, type ReviewerHistory, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs, type PlannerBinding } from "./board.ts";
|
|
45
|
+
import { parseClaimVerdict, claimListId, CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskCanPlan, taskHasWorkEvidence, taskHasStarted, planningUndoEntry, isClosedTaskState, type TaskStall, type HistoryEntry, type ReviewerHistory, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs, type PlannerBinding } from "./board.ts";
|
|
38
46
|
import type { Bus } from "./bus.ts";
|
|
39
47
|
import { HUB, newEnvelope, NOTE_KINDS, noteLine, sanitize, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
|
|
40
48
|
import type { PiiVerdict, ScreenItem, ScreenRecord } from "./inference.ts";
|
|
41
|
-
import { assign, detectSignals, peerEntry, predictSplit, relayCandidates, type Assignment, type InstanceRoutingOptions, type PeerCapabilities, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
|
|
49
|
+
import { assign, isRemotePeerId, detectSignals, peerEntry, predictSplit, relayCandidates, type Assignment, type InstanceRoutingOptions, type PeerCapabilities, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
|
|
42
50
|
import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
|
|
43
51
|
import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
|
|
44
52
|
import { realPath } from "./project.ts";
|
|
@@ -49,8 +57,13 @@ import { coverageGap, renderReviewBrief, type ReviewBrief } from "./review-brief
|
|
|
49
57
|
import { definitionOf as builtinDefinitionOf } from "../adapters/registry.ts";
|
|
50
58
|
import { ADAPTERS, DEFAULT_REVIEW_POOL, definitionOf, familyOf, type ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
51
59
|
import { plannerGrooming, nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
|
|
60
|
+
import { CiWatchers, pollCi, type CiOutcome, type CiWatch } from "./ci-watch.ts";
|
|
52
61
|
import { planningIssueReceipt, linkedReceipt, openPrFor, readPrCostHistory, readReviewReadiness, reviewReadinessFromEvidence, type PrEvidence, type ReviewReadiness, readPlanIssueMetadata, canonicalStage, checklistOf, readSdlcPr, readSpecEdit, sdlcConfig, sdlcReceipt, sdlcStatus, specState, SPEC_STATE_TEXT, stageDefinition, lifecycleStage, type SdlcConfig, type MergeRecord, type PlanIssueMetadata, sdlcGh, type SdlcGh, type SdlcStatus, type SpecEdit, type SpecState } from "./sdlc.ts";
|
|
53
62
|
|
|
63
|
+
function requireLocalTaskPeer(peer: unknown): void {
|
|
64
|
+
if (isRemotePeerId(peer)) throw new PreEffectToolRefusal("remote peers have no local task authority");
|
|
65
|
+
}
|
|
66
|
+
|
|
54
67
|
export type TaskAssignmentRole = "owner" | "reviewer" | "planner";
|
|
55
68
|
|
|
56
69
|
export interface ReleaseWindow { at: number; host: boolean; quota: boolean; idle: boolean; reasons: string[]; current?: () => boolean; close?: () => Promise<void>; }
|
|
@@ -67,6 +80,10 @@ export interface TasksDeps {
|
|
|
67
80
|
board: Board;
|
|
68
81
|
bus: Bus;
|
|
69
82
|
routing: () => Routing;
|
|
83
|
+
/** #800: normalized machine-local admission policy; absent or both zero preserve the old queue behaviour. */
|
|
84
|
+
queueBackpressure?: () => QueueBackpressurePolicy;
|
|
85
|
+
/** Host-only native context snapshots, separate from checkpoint notification and quota. */
|
|
86
|
+
contextAdmission?: () => ContextAdmission;
|
|
70
87
|
/** Startup-bound project declarations; routing never discovers instances from peer names. */
|
|
71
88
|
instances?: () => InstancesConfig;
|
|
72
89
|
/** Host proof of each live extra runtime's actual isolated execution root; config paths alone are not proof. */
|
|
@@ -98,6 +115,10 @@ export interface TasksDeps {
|
|
|
98
115
|
/** Host-verified existing symbols at this task's admitted completion head; never plan guesses. */
|
|
99
116
|
memorySymbolNodes?: (task: Task) => MemorySymbolNodes | undefined;
|
|
100
117
|
sdlc?: { config: () => SdlcConfig; gh?: SdlcGh };
|
|
118
|
+
/** #785 P1b: the per-head CI watcher's bounds; tests shorten them, the defaults are `CI_WATCH_*` in `ci-watch.ts`. */
|
|
119
|
+
ciWatch?: { deadlineMs?: number; pollMs?: number };
|
|
120
|
+
/** #785 P1c: `tasks.auto_pull`, read at each capacity release so a reload applies to the next one; absent or false pulls nothing. */
|
|
121
|
+
autoPull?: () => boolean;
|
|
101
122
|
briefs?: Briefs;
|
|
102
123
|
/** Screened live fail notes directly intersecting the task plan or refs; read-only advice. */
|
|
103
124
|
planFails?: (task: Task) => NativeItem[];
|
|
@@ -115,6 +136,11 @@ export interface TasksDeps {
|
|
|
115
136
|
reviewAuthority?: (by: PeerId, task: number) => (() => string | undefined) | undefined;
|
|
116
137
|
/** Host-captured current-child authority, rechecked before an admitted async tool first commits effects. */
|
|
117
138
|
requestAuthority?: (by: PeerId) => (() => string | undefined) | undefined;
|
|
139
|
+
/**
|
|
140
|
+
* #933: whether `by` runs this call inside an admitted, audited conductor operation. The host rechecks the role on every call and throws
|
|
141
|
+
* its authority refusal when it ended; a peer outside such a call gets `false`. Host-only: never read from configuration or a tool.
|
|
142
|
+
*/
|
|
143
|
+
operational?: (by: PeerId) => boolean;
|
|
118
144
|
memory?: MemoryClient;
|
|
119
145
|
/** Captured native profile fence at our memory operation boundaries; no board mutation. */
|
|
120
146
|
memoryCheck?: () => void;
|
|
@@ -124,6 +150,8 @@ export interface TasksDeps {
|
|
|
124
150
|
notify: (line: string) => void;
|
|
125
151
|
/** The completion check configured for a class, if any (local config only; issue #7). */
|
|
126
152
|
check?: (cls: TaskClass, task?: Task) => string | undefined;
|
|
153
|
+
/** #901: the key of the `checks` entry `check` chose for this task (the profile class alias, else the class): the same lookup, so the two cannot disagree. Absent: the key is unknown. */
|
|
154
|
+
checkKey?: (cls: TaskClass, task?: Task) => string | undefined;
|
|
127
155
|
/** Runs one check; the hub runs them one at a time. */
|
|
128
156
|
runCheck?: (command: string) => Promise<{ code: number | null; timedOut: boolean; interrupted?: boolean; tail: string }>;
|
|
129
157
|
/** Hands a saved note to every other peer; it rides on their next delivery. */
|
|
@@ -154,6 +182,8 @@ export interface TasksDeps {
|
|
|
154
182
|
roles?: Record<string, string[]>;
|
|
155
183
|
/** Optional: name a class for a task proposed without one. `onCampus` says whether the model call stays on campus. */
|
|
156
184
|
triage?: { classify: (title: string, detail: string) => Promise<TaskClass | undefined>; onCampus: () => Promise<boolean> };
|
|
185
|
+
/** #334: natural-language intake. `split` is the hub model's one call, `partsSchema` the profile's `hub_backlog_split` parts schema, `sourceKnown` the source registry. */
|
|
186
|
+
intake?: { split: (system: string, user: string, opts: { campusOnly: boolean }) => Promise<string | undefined>; partsSchema: () => unknown; sourceKnown: (id: string) => boolean };
|
|
157
187
|
/** The PII screen (issue #198): a closed verdict from a model on this machine or on campus, `unknown` on any failure. */
|
|
158
188
|
piiScreen?: (text: string) => Promise<PiiVerdict>;
|
|
159
189
|
/** Each screen verdict, for the record: item, label, source and category, never the text. */
|
|
@@ -186,27 +216,44 @@ export interface TasksDeps {
|
|
|
186
216
|
/** A cohort formed, changed or was lifted (issue #107), for the record: the benchmark's treatment check reads it. */
|
|
187
217
|
recordCohort?: (cohort: { id: number; event: "formed" | "joined" | "lifted"; silent: boolean; tasks: number[]; owners: PeerId[] }) => void;
|
|
188
218
|
sweep?: TaskSweepConfig;
|
|
189
|
-
/**
|
|
219
|
+
/** #798 D1: the project's declared submodule paths, project-relative. Absent: none are known, so no notice. */
|
|
220
|
+
submodules?: () => string[];
|
|
221
|
+
/** Shutdown or coordinator work (recovery) holds task sweeps, and a guarded merge at its dispatch (#491). */
|
|
190
222
|
sweepHeld?: () => boolean;
|
|
191
223
|
/** Consecutive changes_requested verdicts that move a task to another peer (`tasks.escalate_after`, #488). */
|
|
192
224
|
escalateAfter?: () => number;
|
|
225
|
+
/** #489 H1: current host configuration, read at each planner-notice flush; no task/request override. */
|
|
226
|
+
groomFyiWindowMin?: () => number;
|
|
227
|
+
/** #489 H2: current host configuration, read at each use (no cache, no task/request override); absent is the constant each replaces. */
|
|
228
|
+
demoteAt?: () => number;
|
|
229
|
+
demotionHalfLifeH?: () => number;
|
|
230
|
+
contradictionWindowDays?: () => number;
|
|
231
|
+
rescreenMax?: () => number;
|
|
232
|
+
splitMinObservations?: () => number;
|
|
233
|
+
splitMaxFailureShare?: () => number;
|
|
193
234
|
}
|
|
194
235
|
|
|
195
236
|
/** The `tasks.escalate_after` default (#488): the third consecutive changes_requested escalates. */
|
|
196
237
|
export const ESCALATE_AFTER = 3;
|
|
197
238
|
// Demotion (issue #36): a failure counts half after a day. A peer is demoted for a class once its decayed failures
|
|
198
239
|
// reach 1.5 (two within about half a day, three within two days) and outweigh its decayed successes there.
|
|
199
|
-
//
|
|
200
|
-
const DEMOTION_HALF_LIFE_MS = 24 * 3_600_000;
|
|
201
|
-
const DEMOTE_AT = 1.5;
|
|
202
|
-
/** An approval counts as contradicted when work on the same places fails within this window (issue #35). */
|
|
203
|
-
const CONTRADICTION_WINDOW_MS = 7 * 86_400_000;
|
|
240
|
+
// #489 H2: `tasks.demotion_half_life_h` and `tasks.demote_at` set these; unset they are exactly the constants below (pinned in test/operational-settings.test.ts).
|
|
241
|
+
export const DEMOTION_HALF_LIFE_MS = 24 * 3_600_000;
|
|
242
|
+
export const DEMOTE_AT = 1.5;
|
|
243
|
+
/** An approval counts as contradicted when work on the same places fails within this window (issue #35); `tasks.contradiction_window_days` sets it. */
|
|
244
|
+
export const CONTRADICTION_WINDOW_MS = 7 * 86_400_000;
|
|
204
245
|
const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
|
|
246
|
+
const QUEUE_PRESSURE_CODES: readonly QueueBackpressureCode[] = ["queue-count", "queue-age", "queue-age-unknown"];
|
|
247
|
+
const isQueuePressureCode = (code: string): code is QueueBackpressureCode => (QUEUE_PRESSURE_CODES as readonly string[]).includes(code);
|
|
248
|
+
const QUEUE_RECHECK_PAGE = 32;
|
|
249
|
+
interface QueuePressurePass { ownerAfter: number; reviewAfter: number; ownerDone: boolean; reviewDone: boolean; retained: boolean; generation: number }
|
|
205
250
|
/** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
|
|
206
251
|
const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep", "memory reference started"]);
|
|
207
252
|
QUIET_EVENTS.add("ready held"); // #655: the hub's note that a ready task waits for its reserved owner's WIP slot.
|
|
208
253
|
QUIET_EVENTS.add("plan observed"); // Host observation does not move the task or replace completion intent.
|
|
209
254
|
QUIET_EVENTS.add("planner changed"); // Planning authority does not move the execution owner or revoke its handoff.
|
|
255
|
+
QUIET_EVENTS.add("claim verdict"); // #901: a person's bookkeeping on a plan claim moves nothing, so it must not turn a running check into "check finished late".
|
|
256
|
+
QUIET_EVENTS.add("shell branch bound"); // #880: the hub's host-observed branch binding (#845) is bookkeeping; it neither moves nor settles the task.
|
|
210
257
|
/** The records of the `done (checking)` entry a passed check now completes: what the owner delivered then (#509a). */
|
|
211
258
|
const latestPendingDeliverables = (task: Task): DeliverableRecord[] | undefined => {
|
|
212
259
|
const checking = task.history.findLastIndex(entry => entry.event === "done (checking)");
|
|
@@ -306,11 +353,11 @@ const planText = (plan: TaskPlan = {}) => ALL_PLAN_KEYS.filter((k) => plan[k]?.l
|
|
|
306
353
|
/** What the PII screen reads of a task (#198): the text the patterns are checked against. */
|
|
307
354
|
const screenText = (t: { title: string; detail: string; plan?: TaskPlan; external?: Task["external"] }) => [t.title, t.detail, planText(t.plan), t.external?.repo, t.external?.url].filter(value => value !== undefined).join("\n");
|
|
308
355
|
/** Re-screens of one task whose verdict was unknown, per hub run (#198). */
|
|
309
|
-
const RESCREEN_MAX = 10;
|
|
356
|
+
export const RESCREEN_MAX = 10;
|
|
310
357
|
/** #758: the planner "board changed" notice. Its body starts with this, so an unsettled copy is found (and replaced) by the prefix. */
|
|
311
358
|
const GROOM_NOTICE_PREFIX = "The board changed";
|
|
312
359
|
/** #758: a board change that needs no plan review is recorded at most once per planner in this window. */
|
|
313
|
-
const GROOM_FYI_WINDOW_MS = 10 * 60_000;
|
|
360
|
+
export const GROOM_FYI_WINDOW_MS = 10 * 60_000;
|
|
314
361
|
|
|
315
362
|
/**
|
|
316
363
|
* The task flow. Adapters and tools never touch the board: every change comes through here, where assignment,
|
|
@@ -354,8 +401,17 @@ export class Tasks {
|
|
|
354
401
|
private sweepConfig: TaskSweepConfig;
|
|
355
402
|
private readonly noWriteTurns = new NoWriteTurns();
|
|
356
403
|
private sweeping = false;
|
|
404
|
+
/** #785 P1b: one bounded CI watcher per exact PR head, and the reviews the hub held as premature, by task id. */
|
|
405
|
+
private readonly ciWatchers: CiWatchers;
|
|
406
|
+
private readonly heldReviews = new Map<number, { head: string; reviewer: PeerId; revision: number }>();
|
|
357
407
|
|
|
358
408
|
constructor(private readonly d: TasksDeps) {
|
|
409
|
+
this.ciWatchers = new CiWatchers({
|
|
410
|
+
...d.ciWatch,
|
|
411
|
+
poll: watch => pollCi(watch, watch.head, d.sdlc?.config().requiredChecks ?? [], d.sdlc?.gh ?? sdlcGh),
|
|
412
|
+
current: watch => this.ciWatchCurrent(watch),
|
|
413
|
+
finish: (watch, outcome) => this.ciCompleted(watch, outcome),
|
|
414
|
+
});
|
|
359
415
|
d.board.trackPlanRevisions = () => (d.sdlc?.config().mode ?? "off") !== "off";
|
|
360
416
|
this.repositories = new TaskRepositories(d.cwd, d.repository);
|
|
361
417
|
d.board.proposalPlanner = task => this.proposalPlanner(task);
|
|
@@ -409,15 +465,16 @@ export class Tasks {
|
|
|
409
465
|
}
|
|
410
466
|
|
|
411
467
|
/** `signals.pii_screen = "local"` where the PII constraint applies (issue #198); on without a screener, every verdict is unknown. */
|
|
412
|
-
|
|
468
|
+
screenOn = (): boolean => {
|
|
413
469
|
const r = this.d.routing();
|
|
414
470
|
return r.signals.pii_screen === "local" && r.constraints.pii === "local_only";
|
|
415
471
|
};
|
|
416
472
|
|
|
417
473
|
/** One bounded screen call. A screener that fails is no verdict, handled as PII (fail closed). */
|
|
418
|
-
private requestCheck(by: PeerId): () => void {
|
|
474
|
+
private requestCheck(by: PeerId, fence?: () => string | undefined): () => void {
|
|
475
|
+
requireLocalTaskPeer(by);
|
|
419
476
|
const authority = this.d.requestAuthority?.(by);
|
|
420
|
-
const check = () => { const reason = authority?.(); if (reason) throw new PreEffectToolRefusal(reason); };
|
|
477
|
+
const check = () => { const reason = authority?.() ?? fence?.(); if (reason) throw new PreEffectToolRefusal(reason); };
|
|
421
478
|
check(); return check;
|
|
422
479
|
}
|
|
423
480
|
|
|
@@ -536,6 +593,7 @@ export class Tasks {
|
|
|
536
593
|
|
|
537
594
|
/** Captured at native turn start, not supplied by a model. */
|
|
538
595
|
ownerTurnTasks(peer: PeerId): { id: number; acceptance: number }[] {
|
|
596
|
+
requireLocalTaskPeer(peer);
|
|
539
597
|
return this.d.board.list("in_progress").flatMap(task => {
|
|
540
598
|
const accepted = task.owner === peer && ["implement", "bulk_edit"].includes(task.class) ? this.ownerAcceptance(task) : undefined;
|
|
541
599
|
return accepted ? [{ id: task.id, acceptance: accepted.index }] : [];
|
|
@@ -543,6 +601,7 @@ export class Tasks {
|
|
|
543
601
|
}
|
|
544
602
|
|
|
545
603
|
recordOwnerTurn(peer: PeerId, bindings: readonly { id: number; acceptance: number }[], changed: boolean | undefined, effect: boolean): void {
|
|
604
|
+
requireLocalTaskPeer(peer);
|
|
546
605
|
this.noWriteTurns.retain(this.d.board.list("in_progress").filter(task => ["implement", "bulk_edit"].includes(task.class)).map(task => task.id));
|
|
547
606
|
const current = new Map(this.ownerTurnTasks(peer).map(binding => [binding.id, binding.acceptance]));
|
|
548
607
|
for (const binding of bindings) {
|
|
@@ -647,6 +706,33 @@ export class Tasks {
|
|
|
647
706
|
}
|
|
648
707
|
return new Set(rows.map(row => find(row.id))).size;
|
|
649
708
|
}
|
|
709
|
+
/**
|
|
710
|
+
* #514a: the same recurrence path for recurring UNMET CRITERIA. A criterion id that reviews keep naming unmet on the same profile and class, on at least
|
|
711
|
+
* `threshold` tasks that are at least two separate pieces of work (`workUnits`), files one backlog card for a person to review, with the `profile_overrides`
|
|
712
|
+
* fragment as text. Nothing is applied. A PII task and a withheld or private-turn review are never evidence and never named; the card carries ids and counts only.
|
|
713
|
+
*/
|
|
714
|
+
private proposeCriterionCandidates(task: Task, by: PeerId, withheld: boolean): void {
|
|
715
|
+
if (withheld || this.isPii(task) || this.d.privateTurn?.(by) || !task.history.at(-1)?.unmetCriteria?.length) return;
|
|
716
|
+
const rows = this.d.board.list();
|
|
717
|
+
const keys = new Set(rows.filter(row => row.class === "plan" && row.history[0]?.by === HUB)
|
|
718
|
+
.flatMap(row => row.detail?.split("\n").filter(line => line.startsWith("Criterion candidate key: ")) ?? []));
|
|
719
|
+
// Only the rows that may count: candidateTextAllowed refuses a PII row and one whose text the screen refuses, so those are evidence of nothing.
|
|
720
|
+
const eligible = rows.filter(row => this.candidateTextAllowed(`${row.profile ?? ""} ${row.profileClass ?? ""} ${row.class}`, row));
|
|
721
|
+
const named = new Set(task.history.at(-1)!.unmetCriteria);
|
|
722
|
+
const created: number[] = [];
|
|
723
|
+
// No await separates the durable dedupe read from creation; concurrent verdicts see the first card.
|
|
724
|
+
const groups = groupUnmetCriteria(eligible, { windowMs: LEARNING_DEFAULTS.windowMs, now: Date.now() });
|
|
725
|
+
const unitsOf = (group: CriterionGroup) => this.workUnits(eligible.filter(row => group.tasks.includes(row.id)));
|
|
726
|
+
for (const group of selectGroups(groups, { named, threshold: LEARNING_DEFAULTS.threshold, max: LEARNING_DEFAULTS.maxGroups, has: g => keys.has(criterionKey(g)), units: unitsOf })) {
|
|
727
|
+
const card = candidateCard(group, Math.round(LEARNING_DEFAULTS.windowMs / 86_400_000), unitsOf(group));
|
|
728
|
+
const draft = { title: card.title, detail: card.detail, refs: { paths: ["docs/profiles.md"] } };
|
|
729
|
+
const signals = this.signalsFor(draft);
|
|
730
|
+
if (this.isPii({ signals }) || !this.candidateTextAllowed(`${card.title}\n${card.detail}`)) continue;
|
|
731
|
+
const proposal = this.d.board.propose(HUB, { ...draft, class: "plan", backlog: true, signals });
|
|
732
|
+
this.incomingBacklog.add(proposal.id); keys.add(criterionKey(group)); created.push(proposal.id);
|
|
733
|
+
}
|
|
734
|
+
for (const id of created) this.releaseNotice(`task #${id}: recurring unmet criterion candidate proposed in the backlog`);
|
|
735
|
+
}
|
|
650
736
|
private proposeRecurringFindings(task: Task, by: PeerId, items: readonly ReviewFinding[], withheld: boolean): void {
|
|
651
737
|
if (withheld || this.isPii(task) || this.d.privateTurn?.(by) || !items.length || !this.findingTextAllowed(task, items)) return;
|
|
652
738
|
const rows = this.d.board.list();
|
|
@@ -683,7 +769,14 @@ export class Tasks {
|
|
|
683
769
|
|
|
684
770
|
publicView(task: Task, fullHistory = false): Record<string, unknown> {
|
|
685
771
|
const { title, detail, history, external, cancelled, codeSymbols, ...taskRest } = task;
|
|
686
|
-
const
|
|
772
|
+
const plannerBinding = history.findLast(h => h.planner === task.planner && h.plannerReason !== undefined);
|
|
773
|
+
const ownerBinding = history.findLast(h => h.owner === task.owner && ["assigned", "reassigned", "escalated", "backlog pulled"].includes(h.event));
|
|
774
|
+
const reviewerBinding = history.findLast(h => ["assigned", "reassigned", "escalated", "done", "reviewer changed", "reviewer unavailable"].includes(h.event));
|
|
775
|
+
const rest = { ...taskRest,
|
|
776
|
+
...(plannerBinding?.plannerReason && ["rotation", "named", "kept", "none"].includes(plannerBinding.plannerReason) ? { plannerReason: plannerBinding.plannerReason } : {}),
|
|
777
|
+
...(ownerBinding?.reason && TASK_MOVE_REASONS.includes(ownerBinding.reason) ? { ownerReason: ownerBinding.reason === "manual" && ownerBinding.event === "reassigned" && ownerBinding.by !== HUB ? "named" : ownerBinding.reason } : {}),
|
|
778
|
+
...(reviewerBinding?.reviewerReason && ["rotation", "implementer skip", "unavailable skip", "same-family fallback"].includes(reviewerBinding.reviewerReason) ? { reviewerReason: reviewerBinding.reviewerReason } : reviewerBinding?.event === "reviewer changed" && reviewerBinding.reason === "manual" ? { reviewerReason: "named" } : {}),
|
|
779
|
+
...(history.some(h => h.by === USER && h.planDeparture) ? { planDepartureAccepted: true } : {}) };
|
|
687
780
|
if (this.isPii(task)) return { ...rest, actualDates: actualTaskDates(task), refs: {}, plan: {}, ...(task.sdlc ? { sdlc: { stage: stageDefinition(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc.stage)?.base ?? "unknown" } } : {}), title: "[pii]", detail: "[pii]" };
|
|
688
781
|
if (rest.sdlc?.redFirst && !this.publicMetadataText(JSON.stringify(rest.sdlc.redFirst))) {
|
|
689
782
|
const { redFirst: _withheld, ...receipt } = rest.sdlc; rest.sdlc = receipt;
|
|
@@ -708,6 +801,7 @@ export class Tasks {
|
|
|
708
801
|
...(h.unmetCriteria?.length ? { unmetCriteria: h.unmetCriteria } : {}),
|
|
709
802
|
...(h.deliverables?.length ? { deliverables: publicDeliverables(h.deliverables, this.nameable) } : {}),
|
|
710
803
|
...(h.reviewBlocked ? { reviewBlocked: true } : {}),
|
|
804
|
+
...(h.queueReviewHeld ? { queueReviewHeld: true } : {}),
|
|
711
805
|
...(h.reviewerFailure ? { reviewerFailure: { peer: screenHistoryText(h.reviewerFailure.peer, "reviewer"), cause: h.reviewerFailure.cause, ...(h.reviewerFailure.head ? { head: screenHistoryText(h.reviewerFailure.head, "review head") } : {}) } } : {}),
|
|
712
806
|
...(h.planning ? { planning: { revision: h.planning.revision, fields: h.planning.fields, ...(h.planning.split ? { children: h.planning.split.children.map(child => child.id) } : {}) } } : {}),
|
|
713
807
|
...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
|
|
@@ -742,14 +836,15 @@ export class Tasks {
|
|
|
742
836
|
/** Decayed failure weights of the peers demoted for a class: failures count half after a day (issue #36). */
|
|
743
837
|
demoted(cls: TaskClass, now = Date.now()): Record<PeerId, number> {
|
|
744
838
|
const sums = new Map<PeerId, { bad: number; good: number }>();
|
|
839
|
+
const halfLifeH = this.d.demotionHalfLifeH?.(), halfLifeMs = halfLifeH === undefined ? DEMOTION_HALF_LIFE_MS : halfLifeH * 3_600_000, demoteAt = this.d.demoteAt?.() ?? DEMOTE_AT;
|
|
745
840
|
for (const o of this.d.board.outcomes(cls, now - OUTCOMES_KEPT_MS)) {
|
|
746
|
-
const w = 0.5 ** ((now - o.at) /
|
|
841
|
+
const w = 0.5 ** ((now - o.at) / halfLifeMs);
|
|
747
842
|
const s = sums.get(o.peer) ?? { bad: 0, good: 0 };
|
|
748
843
|
if (o.ok) s.good += w;
|
|
749
844
|
else s.bad += w;
|
|
750
845
|
sums.set(o.peer, s);
|
|
751
846
|
}
|
|
752
|
-
return Object.fromEntries([...sums].filter(([, s]) => s.bad >=
|
|
847
|
+
return Object.fromEntries([...sums].filter(([, s]) => s.bad >= demoteAt && s.bad > s.good).map(([p, s]) => [p, s.bad]));
|
|
753
848
|
}
|
|
754
849
|
|
|
755
850
|
/** How each reviewer's reviews of each implementer's work in a class held up (issue #35). */
|
|
@@ -786,20 +881,57 @@ export class Tasks {
|
|
|
786
881
|
}
|
|
787
882
|
|
|
788
883
|
/** Workload is board/queue data only: wip() never calls health() or weights(). */
|
|
789
|
-
private instanceRouting(): InstanceRoutingOptions {
|
|
884
|
+
private instanceRouting(ownerIsolation?: Readonly<Record<string, boolean>>): InstanceRoutingOptions {
|
|
790
885
|
const instances = this.d.instances?.();
|
|
791
886
|
if (!instances || !Object.keys(instances).length) return { adapterCatalog: this.d.adapterCatalog };
|
|
792
887
|
const peers = [...new Set([...(this.d.adapterCatalog?.definitions ?? ADAPTERS).map(adapter => adapter.kind), ...Object.keys(instances), ...this.d.bus.peers.keys()])];
|
|
793
|
-
return { adapterCatalog: this.d.adapterCatalog, instances, ownerIsolation: this.d.ownerIsolation?.() ?? {}, wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])), load: Object.fromEntries(peers.map(peer => [peer, this.d.bus.queued(peer)])) };
|
|
888
|
+
return { adapterCatalog: this.d.adapterCatalog, instances, ownerIsolation: ownerIsolation ?? this.d.ownerIsolation?.() ?? {}, wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])), load: Object.fromEntries(peers.map(peer => [peer, this.d.bus.queued(peer)])) };
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
private queuePolicy(): QueueBackpressurePolicy | undefined {
|
|
892
|
+
const policy = queueBackpressurePolicy(this.d.queueBackpressure?.());
|
|
893
|
+
return policy.max_queued > 0 || policy.max_oldest_s > 0 ? policy : undefined;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/** Admission is pending metadata only; an unavailable read never invents an empty/fresh queue. */
|
|
897
|
+
private queueRouting(peers: readonly PeerId[], policy = this.queuePolicy()): Pick<InstanceRoutingOptions, "queues" | "queueBackpressure"> & { now?: number } {
|
|
898
|
+
if (!policy) return {};
|
|
899
|
+
const queues = Object.fromEntries([...new Set(peers)].map(peer => {
|
|
900
|
+
let value: QueuePressure;
|
|
901
|
+
try { value = this.d.bus.assignmentQueue(peer); } catch { value = { queued: NaN }; }
|
|
902
|
+
return [peer, value];
|
|
903
|
+
}));
|
|
904
|
+
return { queueBackpressure: policy, queues, now: this.d.now?.() ?? Date.now() };
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
private contextRouting(): Pick<InstanceRoutingOptions, "contextAdmission"> & { now?: number } {
|
|
908
|
+
const admission = this.d.contextAdmission?.();
|
|
909
|
+
if (!admission || !Number.isFinite(admission.policy.max_used) || admission.policy.max_used <= 0 || admission.policy.max_used > 1) return {};
|
|
910
|
+
return { contextAdmission: admission, now: this.d.now?.() ?? Date.now() };
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/** Public ids/counts/reasons only; no board history, pending envelope body or task text is read. */
|
|
914
|
+
queuePressureStatus(peers: readonly string[]): QueueBackpressureStatus | undefined {
|
|
915
|
+
const policy = this.queuePolicy();
|
|
916
|
+
if (!policy) return undefined;
|
|
917
|
+
const all = [...new Set(peers)].filter(peer => /^[a-z][a-z0-9-]{0,31}$/.test(peer));
|
|
918
|
+
const bounded = all.slice(0, 4096), input = this.queueRouting(bounded, policy), now = input.now!;
|
|
919
|
+
const rows = Object.fromEntries(bounded.map(peer => {
|
|
920
|
+
const value = peerEntry(input.queues, peer), refusal = queueBackpressure(peer, policy, value, now);
|
|
921
|
+
const queued = value && Number.isSafeInteger(value.queued) && value.queued >= 0 ? value.queued : null;
|
|
922
|
+
const at = queued !== null && queued > 0 && typeof value?.oldestQueuedAt === "number" && Number.isSafeInteger(value.oldestQueuedAt) && value.oldestQueuedAt >= 0 && value.oldestQueuedAt <= now ? value.oldestQueuedAt : null;
|
|
923
|
+
return [peer, { queued, oldestQueuedAt: at, oldestAgeMs: at === null ? null : now - at, blocked: refusal !== undefined, ...(refusal ? { code: refusal.code, reason: refusal.reason } : {}) }];
|
|
924
|
+
}));
|
|
925
|
+
return { policy, peers: rows, ...(all.length > bounded.length ? { truncated: true as const } : {}) };
|
|
794
926
|
}
|
|
795
927
|
|
|
796
928
|
/** Peer health and roles the daemon feeds routing (issues #89, #90, #92): failing peers are skipped, held queues explained. */
|
|
797
|
-
private health() {
|
|
929
|
+
private health(ownerIsolation?: Readonly<Record<string, boolean>>) {
|
|
798
930
|
const failing = this.d.failing?.();
|
|
799
931
|
const held = this.d.held?.();
|
|
800
|
-
const workload = this.instanceRouting();
|
|
932
|
+
const workload = this.instanceRouting(ownerIsolation);
|
|
801
933
|
const peers = workload.instances ? [...new Set([...(this.d.adapterCatalog?.definitions ?? ADAPTERS).map(adapter => adapter.kind), ...Object.keys(workload.instances), ...this.d.bus.peers.keys()])] : (this.d.adapterCatalog?.definitions ?? ADAPTERS).map(adapter => adapter.kind);
|
|
802
|
-
return { ...workload, reviewPool: { pool: this.d.review?.pool ?? DEFAULT_REVIEW_POOL, rerotate: this.d.review?.rerotate, required: this.d.review?.pool !== undefined, ...(this.d.review?.same_family === "never" ? { sameFamily: "never" as const } : {}) }, ...(this.d.capabilities ? { capabilities: this.d.capabilities(), repository: this.d.repository } : {}), pullOnly: [...this.d.bus.peers.values()].filter(peer => (this.d.adapterCatalog?.definitionOf ?? definitionOf)(peer.id, workload.instances)?.attach?.pullOnlyForms?.length && peer.pullOnly).map(peer => peer.id), offRoster: peers.filter(peer => !this.d.bus.isRostered(peer)), ...(failing ? { failing } : {}), ...(held ? { held } : {}), ...(this.d.roles ? { roles: this.d.roles } : {}) };
|
|
934
|
+
return { ...workload, ...this.contextRouting(), ...this.queueRouting([...new Set([...peers, ...this.d.bus.peers.keys()])]), reviewPool: { pool: this.d.review?.pool ?? DEFAULT_REVIEW_POOL, rerotate: this.d.review?.rerotate, required: this.d.review?.pool !== undefined, ...(this.d.review?.same_family === "never" ? { sameFamily: "never" as const } : {}) }, ...(this.d.capabilities ? { capabilities: this.d.capabilities(), repository: this.d.repository } : {}), pullOnly: [...this.d.bus.peers.values()].filter(peer => (this.d.adapterCatalog?.definitionOf ?? definitionOf)(peer.id, workload.instances)?.attach?.pullOnlyForms?.length && peer.pullOnly).map(peer => peer.id), offRoster: peers.filter(peer => !this.d.bus.isRostered(peer)), ...(failing ? { failing } : {}), ...(held ? { held } : {}), ...(this.d.roles ? { roles: this.d.roles } : {}) };
|
|
803
935
|
}
|
|
804
936
|
|
|
805
937
|
private reviewHead(task: Task): string | undefined { return task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead; }
|
|
@@ -819,21 +951,30 @@ export class Tasks {
|
|
|
819
951
|
private poolReview(task: Task, refs?: TaskRefs): Assignment | undefined {
|
|
820
952
|
if (this.manuallyNamedReviewer(task) || this.isPii(task) || task.class === "review") return undefined;
|
|
821
953
|
const draft = { ...task, refs: { ...task.refs, ...cleanRefs(refs) } };
|
|
822
|
-
const
|
|
823
|
-
|
|
954
|
+
const weights = this.weights(task.class, draft);
|
|
955
|
+
const selection = assign(draft, this.states(), this.d.routing(), { ...weights, candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}) });
|
|
956
|
+
const heldSelection = task.reviewer === USER && task.history[this.reviewRequestRevision(task) - 1]?.queueReviewHeld === true;
|
|
957
|
+
const previous = task.reviewer && task.reviewer !== USER ? assign(draft, this.states(), this.d.routing(), { ...weights, candidates: [], namedReviewer: task.reviewer, ...(task.owner ? { notReviewer: task.owner } : {}) }) : undefined;
|
|
958
|
+
const queueRefused = previous?.refusals?.some(row => row.role === "reviewer" && row.peer === task.reviewer && isQueuePressureCode(row.code));
|
|
959
|
+
const newlyBlocked = selection.reviewBlocked && selection.refusals?.some(row => row.role === "reviewer" && isQueuePressureCode(row.code));
|
|
960
|
+
return selection.reviewSelection || queueRefused || newlyBlocked || heldSelection ? selection : undefined;
|
|
824
961
|
}
|
|
825
962
|
|
|
826
|
-
private reviewMetadata(assignment?: Assignment): ReviewerHistory {
|
|
963
|
+
private reviewMetadata(assignment?: Assignment, task?: Task): ReviewerHistory {
|
|
827
964
|
const choice = assignment?.reviewSelection;
|
|
828
965
|
const family = choice && assignment?.plannerFallback && assignment.reviewer ? (this.d.adapterCatalog?.familyOf ?? familyOf)(assignment.reviewer, this.d.instances?.()) : choice?.family;
|
|
829
966
|
const reason = choice && assignment?.plannerFallback
|
|
830
967
|
? assignment.sameFamilyFallback ? "same-family fallback" as const : "unavailable skip" as const
|
|
831
968
|
: choice?.reason;
|
|
832
|
-
|
|
969
|
+
const queueReviewHeld = task !== undefined && !this.isPii(task) && assignment?.reviewer === USER && assignment.reviewBlocked === true
|
|
970
|
+
&& assignment.refusals?.some(row => row.role === "reviewer" && isQueuePressureCode(row.code));
|
|
971
|
+
const queueReviewBinding = task ? this.queueReviewBinding(task) : undefined;
|
|
972
|
+
return { ...(family ? { reviewerFamily: family } : {}), ...(reason ? { reviewerReason: reason } : {}), ...(assignment?.reviewBlocked ? { reviewBlocked: true } : {}), ...(queueReviewHeld ? { queueReviewHeld: true as const, ...(queueReviewBinding !== undefined ? { queueReviewBinding } : {}) } : {}) };
|
|
833
973
|
}
|
|
834
974
|
|
|
835
975
|
/** Capability admission precedes explicit assignments/reservations, independent of readiness or transient peer health. */
|
|
836
976
|
private requireCapabilities(task: Parameters<typeof assign>[0], peer: PeerId, role: Exclude<TaskAssignmentRole, "planner"> = "owner", preEffect = true): void {
|
|
977
|
+
requireLocalTaskPeer(peer);
|
|
837
978
|
const instances = this.d.instances?.(), adapter = (this.d.adapterCatalog?.definitionOf ?? definitionOf)(peer, instances);
|
|
838
979
|
const declaredExtra = !!adapter && adapter.kind !== peer;
|
|
839
980
|
if ((!this.d.capabilities || this.isPii(task)) && !declaredExtra) return;
|
|
@@ -850,13 +991,14 @@ export class Tasks {
|
|
|
850
991
|
* Selection stays in assign(); task bindings only keep that selection while the holder remains eligible.
|
|
851
992
|
*/
|
|
852
993
|
private plannerHolders(): PeerId[] {
|
|
853
|
-
return Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => Array.isArray(roles) && roles.includes("planner") && this.d.bus.isRostered(peer)).map(([peer]) => peer).sort();
|
|
994
|
+
return Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => !isRemotePeerId(peer) && Array.isArray(roles) && roles.includes("planner") && this.d.bus.isRostered(peer)).map(([peer]) => peer).sort();
|
|
854
995
|
}
|
|
855
996
|
|
|
856
|
-
private weights(cls: TaskClass, task?: Task) {
|
|
997
|
+
private weights(cls: TaskClass, task?: Task, projectedHealth?: ReturnType<Tasks["health"]>) {
|
|
857
998
|
const now = Date.now();
|
|
858
999
|
const quota = this.d.quota?.();
|
|
859
|
-
|
|
1000
|
+
const health = projectedHealth ?? this.health(), clock = health.now ?? now;
|
|
1001
|
+
return { ...health, ...(task && this.hasPersonReservation(task) && task.reserved ? { queueReservedHold: task.reserved } : {}), planners: this.plannerHolders(), ...(task ? { reviewPool: this.reviewPoolOptions(task), ...(task.planner ? { keptPlanner: task.planner } : {}) } : {}), now: clock, demoted: this.demoted(cls, clock), reviews: this.reviewRecord(cls), ...(quota ? { quota } : {}), ...(this.d.review?.adaptive ? { adaptive: { min: this.d.review.min_reviews } } : {}) };
|
|
860
1002
|
}
|
|
861
1003
|
|
|
862
1004
|
/**
|
|
@@ -914,6 +1056,8 @@ export class Tasks {
|
|
|
914
1056
|
peers,
|
|
915
1057
|
observations: Object.fromEntries(peers.map((p) => [p, this.splitObservations(task.class, p, task.id)])),
|
|
916
1058
|
units: [unit, unit],
|
|
1059
|
+
...(this.d.splitMinObservations ? { minObservations: this.d.splitMinObservations() } : {}),
|
|
1060
|
+
...(this.d.splitMaxFailureShare ? { maxFailureShare: this.d.splitMaxFailureShare() } : {}),
|
|
917
1061
|
profiles: Object.fromEntries(peers.map((p) => [p, this.d.splitProfile?.(p)])),
|
|
918
1062
|
backlog: Object.fromEntries(peers.map((p) => [p, open.filter((t) => t.owner === p).length])),
|
|
919
1063
|
// Busy is taking the task in question only in the turn that task started (an owner goes busy as its task is
|
|
@@ -931,11 +1075,12 @@ export class Tasks {
|
|
|
931
1075
|
private contradict(failed: Task): void {
|
|
932
1076
|
if (this.isPii(failed)) return; // its places are left out everywhere else too
|
|
933
1077
|
const now = Date.now();
|
|
1078
|
+
const contradictionDays = this.d.contradictionWindowDays?.(), contradictionWindowMs = contradictionDays === undefined ? CONTRADICTION_WINDOW_MS : contradictionDays * 86_400_000;
|
|
934
1079
|
const mine = this.places(failed);
|
|
935
1080
|
for (const t of this.d.board.list("approved")) {
|
|
936
1081
|
if (t.id === failed.id || !t.owner || this.isPii(t)) continue;
|
|
937
1082
|
const approval = [...t.history].reverse().find((h) => h.event === "approved");
|
|
938
|
-
if (!approval || now - approval.at >
|
|
1083
|
+
if (!approval || now - approval.at > contradictionWindowMs || approval.by === USER) continue;
|
|
939
1084
|
const theirs = this.places(t);
|
|
940
1085
|
// Blame needs the same file or symbol: a directory or `.` would contradict every approval under it. Older rows
|
|
941
1086
|
// were stored as written, so both sides are compared in one spelling.
|
|
@@ -953,12 +1098,13 @@ export class Tasks {
|
|
|
953
1098
|
|
|
954
1099
|
/** A removed/off-roster binding falls back to legacy holders; a busy, paused or offline holder keeps authority. */
|
|
955
1100
|
taskPlanner(task: Pick<Task, "planner">): PeerId[] {
|
|
956
|
-
const holders = Object.keys(this.d.roles ?? {}).filter(peer => this.d.roles?.[peer]?.includes("planner")).sort();
|
|
1101
|
+
const holders = Object.keys(this.d.roles ?? {}).filter(peer => !isRemotePeerId(peer) && this.d.roles?.[peer]?.includes("planner")).sort();
|
|
957
1102
|
return task.planner && holders.includes(task.planner) && this.d.bus.isRostered(task.planner) ? [task.planner] : holders;
|
|
958
1103
|
}
|
|
959
1104
|
|
|
960
1105
|
/** Current planner rulings amend review context, never person-only plan-departure acceptance. */
|
|
961
1106
|
plannerRulings(task: Task, recipient: PeerId): { historyIndex: number; by: PeerId; at: number; note: string }[] {
|
|
1107
|
+
requireLocalTaskPeer(recipient);
|
|
962
1108
|
if (this.isPii(task)) return [];
|
|
963
1109
|
const boundary = task.history.findLastIndex(entry => entry.event === "accepted" || entry.event === "claimed");
|
|
964
1110
|
if (boundary < 0) return [];
|
|
@@ -976,13 +1122,15 @@ export class Tasks {
|
|
|
976
1122
|
}
|
|
977
1123
|
|
|
978
1124
|
private planningAuthority(by: PeerId, task?: Pick<Task, "planner">): void {
|
|
1125
|
+
requireLocalTaskPeer(by);
|
|
979
1126
|
if (by !== USER && (!this.d.roles?.[by]?.includes("planner") || task !== undefined && !this.taskPlanner(task).includes(by))) throw new PreEffectToolRefusal(`planning requires a person or configured planner role${task ? "; only the task's effective planner may act" : ""}`);
|
|
980
1127
|
}
|
|
981
1128
|
|
|
982
1129
|
/** #812 AC5: a peer holding the configured planner role. The conductor's task reads accept it; it grants no write. */
|
|
983
|
-
isConfiguredPlanner(peer: PeerId): boolean { return peer !== USER && peer !== HUB && !!this.d.roles?.[peer]?.includes("planner"); }
|
|
1130
|
+
isConfiguredPlanner(peer: PeerId): boolean { return !isRemotePeerId(peer) && peer !== USER && peer !== HUB && !!this.d.roles?.[peer]?.includes("planner"); }
|
|
984
1131
|
|
|
985
1132
|
private plannerWriteAuthority(by: PeerId, task?: Pick<Task, "planner">): void {
|
|
1133
|
+
requireLocalTaskPeer(by);
|
|
986
1134
|
if (by !== USER && this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("planner writes are unavailable during a private turn");
|
|
987
1135
|
this.planningAuthority(by, task);
|
|
988
1136
|
}
|
|
@@ -1048,6 +1196,7 @@ export class Tasks {
|
|
|
1048
1196
|
|
|
1049
1197
|
/** Host-observed task branch/root binding (#845), never a tool-call branch claim or an execution grant. */
|
|
1050
1198
|
bindShellBranch(peer: PeerId, id: number, binding: TaskShellBinding & { worktree: string }, current: () => boolean): boolean {
|
|
1199
|
+
requireLocalTaskPeer(peer);
|
|
1051
1200
|
try {
|
|
1052
1201
|
if (!Number.isSafeInteger(id) || id < 1 || !taskShellBindingValid(binding) || !this.publicMetadataText(binding.branch)
|
|
1053
1202
|
|| !current() || this.d.privateTurn?.(peer)) return false;
|
|
@@ -1092,7 +1241,8 @@ export class Tasks {
|
|
|
1092
1241
|
if (id !== undefined) change.ready.add(id);
|
|
1093
1242
|
changes.set(planner, change);
|
|
1094
1243
|
};
|
|
1095
|
-
|
|
1244
|
+
const released = new Set<PeerId>();
|
|
1245
|
+
for (const [peer, used] of before) if (this.wip(peer) < used) { released.add(peer); for (const planner of this.taskPlanner(task)) add(planner, peer); }
|
|
1096
1246
|
if (approved) for (const card of this.d.board.list("backlog")) {
|
|
1097
1247
|
if (card.deps?.includes(task.id) && !this.waitsFor(card).length) for (const planner of this.taskPlanner(card)) add(planner, undefined, card.id);
|
|
1098
1248
|
}
|
|
@@ -1107,9 +1257,7 @@ export class Tasks {
|
|
|
1107
1257
|
}
|
|
1108
1258
|
const lines: string[] = [];
|
|
1109
1259
|
for (const peer of change.capacity) {
|
|
1110
|
-
const top = this.
|
|
1111
|
-
&& this.taskPlanner(card).includes(planner) && !card.pinned && taskCanPlan(card) && !this.waitsFor(card).length && !this.excluded(card).includes(peer)
|
|
1112
|
-
&& (card.release == null || ["planning", "active"].includes(this.d.board.release(card.release)?.state ?? ""))).sort(this.readyOrder)[0];
|
|
1260
|
+
const top = this.topReservedReady(peer, planner);
|
|
1113
1261
|
lines.push(`capacity_released: ${peer} WIP ${this.wip(peer)}; reserved ready ${top ? `#${top.id}` : "none"}`);
|
|
1114
1262
|
}
|
|
1115
1263
|
for (const id of change.ready) {
|
|
@@ -1121,9 +1269,54 @@ export class Tasks {
|
|
|
1121
1269
|
const env = newEnvelope(HUB, sanitize(`Planner work ready:\n${lines.join("\n")}\nRead the current board before planning or pulling work.`), { to: [planner], kind: "task" });
|
|
1122
1270
|
if (!this.d.bus.publish(env).includes(planner)) throw new Error("the notice was not queued");
|
|
1123
1271
|
});
|
|
1272
|
+
// #785 P1c: only this write's own release, never a merged earlier one, and only when the person turned it on.
|
|
1273
|
+
if (this.d.autoPull?.() === true) this.queueAutoPulls(released);
|
|
1124
1274
|
});
|
|
1125
1275
|
}
|
|
1126
1276
|
|
|
1277
|
+
/** The top card explicitly reserved for `peer` that this planner may pull now; the notice and the opt-in pull read the same rule. `pullable` keeps the staged backlog cards only. */
|
|
1278
|
+
private topReservedReady(peer: PeerId, planner?: PeerId, pullable = false): Task | undefined {
|
|
1279
|
+
return this.d.board.list().filter(card => (pullable ? card.state === "backlog" : ["backlog", "proposed"].includes(card.state)) && !card.owner && card.reserved === peer
|
|
1280
|
+
&& (planner === undefined || this.taskPlanner(card).includes(planner)) && !(pullable && this.isPii(card)) && !card.pinned && !card.draft && taskCanPlan(card) && !this.waitsFor(card).length && !this.excluded(card).includes(peer)
|
|
1281
|
+
&& (card.release == null || ["planning", "active"].includes(this.d.board.release(card.release)?.state ?? ""))).sort(this.readyOrder)[0];
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/**
|
|
1285
|
+
* #785 P1c: `tasks.auto_pull`. A slot freed for a peer pulls the one top backlog card explicitly reserved for it, through `pullPlanned` as the hub acting for that card's planner of
|
|
1286
|
+
* record: revision, dependency, reservation, pinned, frozen-release, P0 order and WIP checks all run there unchanged. One card per release. Never an unreserved card, never PII.
|
|
1287
|
+
* Serialized on one chain, so two releases cannot pick the same card; each job re-reads the board, so a card that moved meanwhile is skipped.
|
|
1288
|
+
*/
|
|
1289
|
+
private autoPulls: Promise<void> = Promise.resolve();
|
|
1290
|
+
private autoPullsOpen = 0;
|
|
1291
|
+
/** Queued or running auto pulls, so a recovery commit waits for them the way it waits for `checksPending` (#785 P1c): one that is mid-admission would otherwise write after the commit's digest. */
|
|
1292
|
+
autoPullsPending = (): number => this.autoPullsOpen;
|
|
1293
|
+
private queueAutoPulls(released: ReadonlySet<PeerId>): void {
|
|
1294
|
+
for (const peer of released) {
|
|
1295
|
+
this.autoPullsOpen++;
|
|
1296
|
+
this.autoPulls = this.autoPulls.then(() => this.autoPull(peer)).catch(() => { /* autoPull reports its own refusals */ }).finally(() => { this.autoPullsOpen--; });
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
/** Whether the hub may still start work on its own: the setting is on, and no recovery, shutdown or storage hold is active. Read at entry and again right before the assignment write. */
|
|
1300
|
+
private autoPullAdmitted(): boolean {
|
|
1301
|
+
return this.d.autoPull?.() === true && !this.d.sweepHeld?.() && !this.d.bus.isRecoveryHeld && !this.d.bus.storageError;
|
|
1302
|
+
}
|
|
1303
|
+
private async autoPull(peer: PeerId): Promise<void> {
|
|
1304
|
+
if (!this.autoPullAdmitted()) return;
|
|
1305
|
+
const card = this.topReservedReady(peer, undefined, true);
|
|
1306
|
+
if (!card) return;
|
|
1307
|
+
// The planner of record is the card's own: its bound planner, else the first current holder, and it must be rostered. A private card never gets here (topReservedReady skips it).
|
|
1308
|
+
const planner = this.taskPlanner(card)[0];
|
|
1309
|
+
if (!planner || !this.d.bus.isRostered(planner)) return;
|
|
1310
|
+
const reason = `auto_pull: capacity released for ${peer}; hub acted for planner ${planner}`;
|
|
1311
|
+
try {
|
|
1312
|
+
await this.pullPlanned(HUB, { id: card.id, revision: card.planningRevision ?? 0, reason }, { onBehalfOf: planner, guard: () => { if (!this.autoPullAdmitted()) throw new PreEffectToolRefusal("auto pull is no longer admitted: tasks.auto_pull was turned off or a recovery, shutdown or storage hold began"); } });
|
|
1313
|
+
} catch (e) {
|
|
1314
|
+
// A guard refusal is the setting or a hold doing its job, not a failure to report.
|
|
1315
|
+
if (!this.autoPullAdmitted()) return;
|
|
1316
|
+
this.releaseNotice(`task ${this.publicTitle(card)}: auto_pull for ${peer} refused: ${e instanceof Error ? e.message : String(e)}`);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1127
1320
|
/** #784 AC3: the read-only breakdown of `wip` for status, the console and the dashboard: one grouped query for every peer. Admission still reads `wip()`. */
|
|
1128
1321
|
wipBreakdown(peers: readonly PeerId[]): Record<string, { wip: number; active: number; reviewWait: number; reserved: number; wipLimit: number }> {
|
|
1129
1322
|
const grouped = this.d.board.plannerWipBreakdown();
|
|
@@ -1148,6 +1341,7 @@ export class Tasks {
|
|
|
1148
1341
|
plannerRefusal(task: Task): string {
|
|
1149
1342
|
const parts: string[] = [];
|
|
1150
1343
|
const excluded = [...new Set(this.excluded(task))];
|
|
1344
|
+
const pressure = this.queueRouting(Object.keys(this.states()));
|
|
1151
1345
|
if (excluded.length) parts.push(`${excluded.join(", ")} declined or was escalated away from this task for good; only a person's ahub task assign lifts that`);
|
|
1152
1346
|
for (const peer of Object.keys(this.states())) {
|
|
1153
1347
|
if (excluded.includes(peer)) continue;
|
|
@@ -1155,25 +1349,37 @@ export class Tasks {
|
|
|
1155
1349
|
if (wip >= limit) parts.push(`${peer} WIP full (${wip}/${limit}); no eligible planner WIP capacity`);
|
|
1156
1350
|
else if (queue.heldBy) parts.push(`${peer} has a held queue (a delivery is held; resolve it with ahub queue list)`);
|
|
1157
1351
|
else if (queue.needsReview > 0) parts.push(`${peer} has ${queue.needsReview} delivery(ies) in needs_review`);
|
|
1352
|
+
const refusal = queueBackpressure(peer, pressure.queueBackpressure, peerEntry(pressure.queues, peer), pressure.now ?? NaN);
|
|
1353
|
+
if (refusal) parts.push(`${peer} ${refusal.reason}`);
|
|
1158
1354
|
}
|
|
1159
1355
|
return parts.length ? `no peer can take the planner pull: ${parts.join("; ")}` : "no peer has eligible planner WIP capacity";
|
|
1160
1356
|
}
|
|
1161
|
-
private plannerAssignment(task: Task, reservedOnly = false, routing: Routing = this.d.routing()): Assignment {
|
|
1357
|
+
private plannerAssignment(task: Task, reservedOnly = false, routing: Routing = this.d.routing(), queueOverride?: "owner", projectedHealth?: ReturnType<Tasks["health"]>): Assignment {
|
|
1162
1358
|
const states = this.states();
|
|
1163
1359
|
const full = this.plannerFull(task);
|
|
1164
1360
|
// Preview the To Do transition without routing or offering the persisted backlog row.
|
|
1165
|
-
return assign({ ...task, state: "proposed" }, states, routing, { exclude: this.excluded(task), excludeOwners: full, ...(reservedOnly && task.reserved ? { candidates: [task.reserved], namedOwner: task.reserved } : {}), waitsFor: this.waitsFor(task), ...this.weights(task.class, task) });
|
|
1361
|
+
return assign({ ...task, state: "proposed" }, states, routing, { exclude: this.excluded(task), excludeOwners: full, ...(reservedOnly && task.reserved ? { candidates: [task.reserved], namedOwner: task.reserved } : {}), waitsFor: this.waitsFor(task), ...this.weights(task.class, task, projectedHealth), ...(queueOverride ? { queueOverride, contextOverride: true } : {}) });
|
|
1166
1362
|
}
|
|
1167
1363
|
|
|
1168
|
-
async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }): Promise<Task> {
|
|
1364
|
+
async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }, opts: { onBehalfOf?: PeerId; guard?: () => void } = {}): Promise<Task> {
|
|
1169
1365
|
// #455: one authority check, made before the screen and again after it (a person may move the planner meanwhile).
|
|
1170
|
-
|
|
1366
|
+
// #785 P1c: `by` is the actor the history records; the hub's opt-in auto pull names the planner it acts for, and that planner's authority is what is checked.
|
|
1367
|
+
if (opts.onBehalfOf !== undefined && by !== HUB) throw new PreEffectToolRefusal("only the hub pulls on a planner's behalf");
|
|
1368
|
+
const planner = opts.onBehalfOf ?? by;
|
|
1369
|
+
let queueAssignment: { current: () => void } | undefined;
|
|
1370
|
+
// `opts.guard` (the auto pull's own admission, #785 P1c) throws a PreEffectToolRefusal once the pull is no longer wanted; it runs wherever authority is rechecked, so it
|
|
1371
|
+
// also runs after the awaited PII screen and right before the assignment write, where a setting turned off or a recovery that began meanwhile must still stop it.
|
|
1372
|
+
const authorized = () => { this.planningAuthority(planner); opts.guard?.(); queueAssignment?.current(); };
|
|
1171
1373
|
authorized();
|
|
1172
1374
|
if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0 || typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("planner pull needs an id, revision and bounded reason");
|
|
1375
|
+
queueAssignment = by !== HUB && opts.onBehalfOf === undefined && this.d.board.get(input.id)?.reserved
|
|
1376
|
+
? this.explicitQueueAssignment(by, "owner", true) : undefined;
|
|
1377
|
+
queueAssignment?.current();
|
|
1173
1378
|
const validate = () => {
|
|
1174
1379
|
const task = this.d.board.get(input.id);
|
|
1380
|
+
if (task?.draft) throw new PreEffectToolRefusal(`task #${task.id} is an intake draft; the person approves it first`);
|
|
1175
1381
|
if (!task || (task.planningRevision ?? 0) !== input.revision || task.state !== "backlog" || task.owner || !taskCanPlan(task) || task.pinned) throw new PreEffectToolRefusal("planner pull changed, pinned or already started");
|
|
1176
|
-
this.planningAuthority(
|
|
1382
|
+
this.planningAuthority(planner, task);
|
|
1177
1383
|
const release = task.release == null ? undefined : this.d.board.release(task.release);
|
|
1178
1384
|
if (task.release != null && (!release || ["frozen", "shipped", "dropped"].includes(release.state))) throw new PreEffectToolRefusal("planner pull requires unfrozen release scope");
|
|
1179
1385
|
if (this.waitsFor(task).length) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
|
|
@@ -1189,7 +1395,7 @@ export class Tasks {
|
|
|
1189
1395
|
})) throw new PreEffectToolRefusal("a person's P0 backlog order is retained");
|
|
1190
1396
|
}
|
|
1191
1397
|
if (task.reserved && this.excluded(task).includes(task.reserved)) throw new PreEffectToolRefusal(this.plannerRefusal(task));
|
|
1192
|
-
const assignment = this.plannerAssignment(task, !!task.reserved);
|
|
1398
|
+
const assignment = this.plannerAssignment(task, !!task.reserved, this.d.routing(), queueAssignment ? "owner" : undefined);
|
|
1193
1399
|
if (!assignment.owner) {
|
|
1194
1400
|
const refusal = task.reserved ? `reserved peer ${task.reserved} cannot take the planner pull: ${assignment.refusals?.find(row => row.role === "owner" && row.peer === task.reserved)?.reason ?? "not eligible"}; ${this.plannerRefusal(task)}` : this.plannerRefusal(task);
|
|
1195
1401
|
throw new PreEffectToolRefusal(`${this.hasPersonReservation(task) ? "a person's reservation is retained; " : ""}${refusal}`);
|
|
@@ -1200,13 +1406,14 @@ export class Tasks {
|
|
|
1200
1406
|
authorized();
|
|
1201
1407
|
const current = validate();
|
|
1202
1408
|
if (current.task.reserved) {
|
|
1203
|
-
return this.assignOwner(current.task, by, { candidates: [current.owner], namedOwner: current.owner, planner: true, refuseNamed: "pre",
|
|
1409
|
+
return this.assignOwner(current.task, by, { candidates: [current.owner], namedOwner: current.owner, planner: true, refuseNamed: "pre", ...(queueAssignment ? { queueOverride: "owner" as const, current: queueAssignment.current } : {}),
|
|
1204
1410
|
plannerPull: { revision: input.revision, note: input.reason.trim(), withheld, guard: () => {
|
|
1205
1411
|
authorized();
|
|
1206
1412
|
const checked = validate();
|
|
1207
1413
|
if (checked.task.history.length !== current.task.history.length || checked.owner !== current.owner) throw new PreEffectToolRefusal("planner pull changed during admission");
|
|
1208
1414
|
} } });
|
|
1209
1415
|
}
|
|
1416
|
+
opts.guard?.();
|
|
1210
1417
|
const pulled = this.d.board.pullPlanned(current.task.id, by, current.owner, input.revision, input.reason.trim(), withheld, this.plannerBinding(current.task, current.assignment, true));
|
|
1211
1418
|
this.planningRecorded({ by, task: pulled.id, fields: ["pull", "reserved"], revision: pulled.planningRevision! });
|
|
1212
1419
|
return pulled;
|
|
@@ -1221,7 +1428,7 @@ export class Tasks {
|
|
|
1221
1428
|
const pending = this.plannerBacklog.get(peer) ?? new Set<number>();
|
|
1222
1429
|
for (const id of ids) {
|
|
1223
1430
|
const task = this.d.board.get(id);
|
|
1224
|
-
if (task?.state === "backlog" && this.taskPlanner(task).includes(peer)) pending.add(id);
|
|
1431
|
+
if (task?.state === "backlog" && !task.draft && this.taskPlanner(task).includes(peer)) pending.add(id);
|
|
1225
1432
|
}
|
|
1226
1433
|
if (pending.size) this.plannerBacklog.set(peer, pending);
|
|
1227
1434
|
}
|
|
@@ -1262,11 +1469,13 @@ export class Tasks {
|
|
|
1262
1469
|
scan.revision = revision;
|
|
1263
1470
|
}
|
|
1264
1471
|
const now = this.d.now?.() ?? Date.now();
|
|
1472
|
+
const configuredFyiMin = this.d.groomFyiWindowMin?.();
|
|
1473
|
+
const fyiWindowMs = configuredFyiMin === undefined ? GROOM_FYI_WINDOW_MS : configuredFyiMin * 60_000;
|
|
1265
1474
|
for (const peer of planners) {
|
|
1266
1475
|
const told = scan.sent.get(peer), pending = scan.pending.get(peer);
|
|
1267
1476
|
const review = told === undefined || pending?.review === true;
|
|
1268
1477
|
if (!review && !pending?.other) continue;
|
|
1269
|
-
if (!review && now - told!.fyiAt <
|
|
1478
|
+
if (!review && now - told!.fyiAt < fyiWindowMs) { scan.pending.delete(peer); continue; }
|
|
1270
1479
|
this.releaseNotice(`could not notify planner ${peer} about grooming`, () => {
|
|
1271
1480
|
if (review) this.sendGroomNotice(peer, revision);
|
|
1272
1481
|
else this.d.bus.publish(newEnvelope(HUB, `${GROOM_NOTICE_PREFIX} (revision ${revision}): state moves, reviews or assignments only. No plan review is needed.`, { to: [peer], kind: "task", priority: "fyi" }));
|
|
@@ -1286,7 +1495,7 @@ export class Tasks {
|
|
|
1286
1495
|
|
|
1287
1496
|
/** One bounded ids-only batch per attached planner; failed or evicted copies stay pending. */
|
|
1288
1497
|
flushPlannerNotices(): number {
|
|
1289
|
-
const configured = Object.entries(this.d.roles ?? {}).filter(([, roles]) => roles.includes("planner")).map(([peer]) => peer);
|
|
1498
|
+
const configured = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => !isRemotePeerId(peer) && roles.includes("planner")).map(([peer]) => peer);
|
|
1290
1499
|
for (const peer of this.knownPlanners) if (!configured.includes(peer)) {
|
|
1291
1500
|
this.knownPlanners.delete(peer); this.plannerBacklog.delete(peer); this.plannerBlockedQueue.delete(peer);
|
|
1292
1501
|
this.groomScan.pending.delete(peer); this.groomScan.sent.delete(peer);
|
|
@@ -1477,7 +1686,7 @@ export class Tasks {
|
|
|
1477
1686
|
}
|
|
1478
1687
|
|
|
1479
1688
|
async importGitHub(by: PeerId, input: GitHubImportSelection, run?: SdlcGh): Promise<{ imported: number[]; existing: number[]; partial: boolean; complete: boolean; unconfirmed?: true }> {
|
|
1480
|
-
if (by
|
|
1689
|
+
if (!this.personOrConductor(by)) throw new PreEffectToolRefusal("GitHub import is a person console command");
|
|
1481
1690
|
const deadline = performance.now() + 20_000;
|
|
1482
1691
|
const page = await readGitHubImport(input, run ?? ((args, timeout, _cwd, bound) => sdlcGh(args, timeout, this.d.cwd, bound)));
|
|
1483
1692
|
const imported: number[] = [], existing: number[] = [];
|
|
@@ -1489,7 +1698,7 @@ export class Tasks {
|
|
|
1489
1698
|
if (prior) { existing.push(prior.id); continue; }
|
|
1490
1699
|
const classes = profileData(this.d.profileData ?? this.d.profile ?? "dev").classes;
|
|
1491
1700
|
const cls = Object.keys(classes).find(name => classes[name] === "implement") ?? Object.keys(classes)[0];
|
|
1492
|
-
const task = await this.propose(
|
|
1701
|
+
const task = await this.propose(by, { title: issue.title, detail: issue.detail, class: cls, backlog: true }, issue.source, () => { created = true; });
|
|
1493
1702
|
(created ? imported : existing).push(task.id);
|
|
1494
1703
|
if (performance.now() >= deadline) return { imported, existing, partial: true, complete: false };
|
|
1495
1704
|
} catch {
|
|
@@ -1505,8 +1714,10 @@ export class Tasks {
|
|
|
1505
1714
|
return { imported, existing, partial: page.partial, complete: !page.partial };
|
|
1506
1715
|
}
|
|
1507
1716
|
|
|
1508
|
-
async splitBacklog(by: PeerId, input: { id: number; revision: number; parts: unknown; reason: string }): Promise<{ parent: Task; children: Task[] }> {
|
|
1509
|
-
|
|
1717
|
+
async splitBacklog(by: PeerId, input: { id: number; revision: number; parts: unknown; reason: string }, opts: { intake?: true } = {}): Promise<{ parent: Task; children: Task[] }> {
|
|
1718
|
+
// #334: the hub writes an intake draft's own model split; no other write by the hub passes as a planner.
|
|
1719
|
+
const authority = (task?: Pick<Task, "planner">) => { if (!(opts.intake && by === HUB)) this.planningAuthority(by, task); };
|
|
1720
|
+
authority();
|
|
1510
1721
|
const { id, revision } = input;
|
|
1511
1722
|
if (!Number.isSafeInteger(id) || id <= 0 || !Number.isSafeInteger(revision) || revision < 0 || typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("split needs an id, revision and bounded reason");
|
|
1512
1723
|
const reason = input.reason.trim();
|
|
@@ -1519,7 +1730,9 @@ export class Tasks {
|
|
|
1519
1730
|
if (!parent || parent.state !== "backlog" || parent.owner || taskHasStarted(parent) || (parent.planningRevision ?? 0) !== revision || by !== USER && parent.pinned) throw new PreEffectToolRefusal("split changed, pinned or already started");
|
|
1520
1731
|
const release = parent.release == null ? undefined : this.d.board.release(parent.release);
|
|
1521
1732
|
if (parent.release != null && (!release || ["shipped", "dropped"].includes(release.state) || by !== USER && release.state === "frozen")) throw new PreEffectToolRefusal("split release scope is frozen or final");
|
|
1522
|
-
|
|
1733
|
+
authority(parent);
|
|
1734
|
+
// #334: the hub writes a split only into a draft that is still a draft. Both validations run (before and after the awaited screen), and the board asks again in its transaction.
|
|
1735
|
+
if (opts.intake && by === HUB && !parent.draft) throw new PreEffectToolRefusal("split changed, pinned or already started");
|
|
1523
1736
|
const profile = parent.profile ?? "dev";
|
|
1524
1737
|
const drafts = rows.map((part, index) => {
|
|
1525
1738
|
if (!part || typeof part !== "object" || Array.isArray(part) || Object.keys(part).some(key => !["title", "detail", "class", "refs", "plan", "after", "afterParts"].includes(key)) || typeof part.title !== "string" || !part.title.trim() || part.title.length > 300 || part.detail !== undefined && (typeof part.detail !== "string" || part.detail.length > 8000) || part.class !== undefined && typeof part.class !== "string") throw new PreEffectToolRefusal("invalid split part");
|
|
@@ -1554,7 +1767,7 @@ export class Tasks {
|
|
|
1554
1767
|
let screened: Awaited<typeof attempt>;
|
|
1555
1768
|
try { screened = await Promise.race([attempt, new Promise<never>((_, reject) => { deadline = setTimeout(() => reject(new PreEffectToolRefusal("split screening request deadline exceeded")), BACKLOG_SPLIT_SCREEN_TIMEOUT_MS); })]); }
|
|
1556
1769
|
finally { if (deadline !== undefined) clearTimeout(deadline); }
|
|
1557
|
-
|
|
1770
|
+
authority(); // #455: a person may have moved the planner while the screen ran
|
|
1558
1771
|
const [screenedVerdicts, withheld] = screened, current = validate();
|
|
1559
1772
|
const finalSignals = current.drafts.map(part => this.signalsFor({ ...part, detail: [part.detail, planText(part.plan)].join("\n") }));
|
|
1560
1773
|
const deterministicPrivate = finalSignals.map((signals, index) => initialPrivate[index] || this.isPii({ signals }));
|
|
@@ -1562,6 +1775,8 @@ export class Tasks {
|
|
|
1562
1775
|
const inherited = current.drafts.map((_part, index) => (initialParentPrivate || this.isPii(current.parent)) && !deterministicPrivate[index]);
|
|
1563
1776
|
const drafts = current.drafts.map((part, index) => ({ ...part, ...(screening ? { screeningNote: inherited[index] ? "pii: inherited" : this.screenNote(verdicts[index]) } : {}), signals: [...new Set([...initialPrivacy[index]!, ...initialParentPrivacy, ...finalSignals[index]!, ...current.parent.signals.filter(signal => ["pii", "pii_path", "urgent"].includes(signal)), ...(verdicts[index] && verdicts[index]!.label !== "clear" ? ["pii"] : [])])] }));
|
|
1564
1777
|
for (const draft of drafts) this.warmRepository(draft);
|
|
1778
|
+
// #334: the hub's intake split is only for a draft; the board asks for it again inside its transaction (a flag set for exactly this write, never left on).
|
|
1779
|
+
this.d.board.requireDraftForNextSplit = opts.intake === true && by === HUB;
|
|
1565
1780
|
const result = this.d.board.splitBacklog(id, by, revision, drafts, reason, withheld || initialReasonPrivate || this.isPii(current.parent) || !this.nameable(reason));
|
|
1566
1781
|
if (this.d.coordination) {
|
|
1567
1782
|
const mode = this.d.coordination();
|
|
@@ -1575,6 +1790,92 @@ export class Tasks {
|
|
|
1575
1790
|
return result;
|
|
1576
1791
|
}
|
|
1577
1792
|
|
|
1793
|
+
/**
|
|
1794
|
+
* The draft and every open draft card split from it, at any depth, parent first. A planner may split an intake child again before approval, and
|
|
1795
|
+
* the grandchildren inherit the mark, so the walk follows `deps` through draft cards only: a dependency on ordinary work is never followed.
|
|
1796
|
+
*/
|
|
1797
|
+
private intakeFamily(id: number): Task[] {
|
|
1798
|
+
const root = this.d.board.get(id);
|
|
1799
|
+
if (!root || !root.draft || root.state !== "backlog") throw new PreEffectToolRefusal(`task #${id} is not an open intake draft`);
|
|
1800
|
+
const family: Task[] = [root], seen = new Set([root.id]);
|
|
1801
|
+
for (let next = 0; next < family.length; next++) {
|
|
1802
|
+
for (const dep of family[next]!.deps ?? []) {
|
|
1803
|
+
if (seen.has(dep)) continue;
|
|
1804
|
+
const child = this.d.board.get(dep);
|
|
1805
|
+
if (!child || !child.draft || child.state !== "backlog") continue;
|
|
1806
|
+
seen.add(dep); family.push(child);
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
return family;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
/**
|
|
1813
|
+
* #334 (#511a): the person's request becomes one parent draft, a backlog card that nothing offers or pulls. A planner is asked to split it with
|
|
1814
|
+
* `hub_backlog_split`; with none, or for a request that is PII, the hub's model proposes the split and the hub writes it. A split that does not
|
|
1815
|
+
* validate leaves the draft whole, with the reason on its history.
|
|
1816
|
+
*/
|
|
1817
|
+
async intake(by: PeerId, input: { text: unknown; sources?: unknown }): Promise<{ draft: Task; children: Task[]; split: "planner" | "hub" | "unsplit"; reason?: string }> {
|
|
1818
|
+
if (by !== USER) throw new PreEffectToolRefusal("intake is a console command");
|
|
1819
|
+
const text = typeof input.text === "string" ? input.text.trim() : "";
|
|
1820
|
+
if (!text || text.length > INTAKE_MAX_TEXT) throw new PreEffectToolRefusal(`intake needs a request of 1 to ${INTAKE_MAX_TEXT} characters`);
|
|
1821
|
+
const given = input.sources ?? [];
|
|
1822
|
+
if (!Array.isArray(given) || given.length > INTAKE_MAX_SOURCES || given.some(id => typeof id !== "string" || !SOURCE_ID.test(id)) || new Set(given).size !== given.length) throw new PreEffectToolRefusal(`sources: at most ${INTAKE_MAX_SOURCES} distinct src:<16 hex> ids`);
|
|
1823
|
+
const sources = given as string[];
|
|
1824
|
+
for (const id of sources) if (!this.d.intake?.sourceKnown(id)) throw new PreEffectToolRefusal(`unknown source ${id}; register it with ahub source add`);
|
|
1825
|
+
const title = `Intake: ${text.split("\n")[0]!.replace(/\s+/g, " ").trim().slice(0, 80)}`;
|
|
1826
|
+
let draft = await this.propose(USER, { title, detail: intakeDetail(text, sources), class: "plan", backlog: true, draft: true });
|
|
1827
|
+
this.incomingBacklog.delete(draft.id);
|
|
1828
|
+
const priv = this.isPii(draft);
|
|
1829
|
+
const planner = priv ? undefined : this.taskPlanner(draft).find(peer => this.d.bus.isRostered(peer) && this.d.bus.peers.has(peer) && this.d.bus.stateOf(peer) !== "offline");
|
|
1830
|
+
if (planner) {
|
|
1831
|
+
const body = plannerRequestBody({ id: draft.id, revision: draft.planningRevision ?? 0, text: parseIntakeDetail(String(this.publicView(draft).detail)).text, sources });
|
|
1832
|
+
try {
|
|
1833
|
+
if (this.d.bus.publish(newEnvelope(HUB, sanitize(body), { to: [planner], kind: "task", refs: { task: String(draft.id), taskRecipient: "planner" } })).includes(planner)) return { draft, children: [], split: "planner" };
|
|
1834
|
+
} catch { /* the planner is not reachable: the hub model takes it */ }
|
|
1835
|
+
}
|
|
1836
|
+
return { ...(await this.intakeByHub(draft, text, sources, priv)), draft: this.d.board.get(draft.id) ?? draft };
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
/** The hub model's split of a draft, validated against the profile's `hub_backlog_split` parts schema and written through `splitBacklog`. */
|
|
1840
|
+
private async intakeByHub(draft: Task, text: string, sources: string[], priv: boolean): Promise<{ children: Task[]; split: "hub" | "unsplit"; reason?: string }> {
|
|
1841
|
+
const unsplit = (reason: string) => { this.d.board.update(draft.id, HUB, "intake unsplit", {}, reason); return { children: [], split: "unsplit" as const, reason }; };
|
|
1842
|
+
if (!this.d.intake) return unsplit("no planner is attached and the hub has no model for a split");
|
|
1843
|
+
// A request that is PII goes only to a model reached on campus; without one it stays whole for the person.
|
|
1844
|
+
if (priv && !(await this.d.triage?.onCampus().catch(() => false))) return unsplit("the request is PII and no on-prem model was reached");
|
|
1845
|
+
let raw: string | undefined;
|
|
1846
|
+
try { raw = await this.d.intake.split(intakeSystemPrompt(Object.keys(profileData(this.d.profileData ?? this.d.profile ?? "dev").classes), this.d.intake.partsSchema()), intakeUserMessage(text, sources), { campusOnly: priv }); } catch { raw = undefined; }
|
|
1847
|
+
const parsed = parseIntakeSplit(raw, this.d.intake.partsSchema(), sources);
|
|
1848
|
+
if ("error" in parsed) return unsplit(parsed.error);
|
|
1849
|
+
const current = this.d.board.get(draft.id);
|
|
1850
|
+
if (!current || !current.draft || current.state !== "backlog") return unsplit("the draft changed while the model answered");
|
|
1851
|
+
try {
|
|
1852
|
+
const result = await this.splitBacklog(HUB, { id: draft.id, revision: current.planningRevision ?? 0, parts: parsed.parts, reason: "intake: split by the hub model" }, { intake: true });
|
|
1853
|
+
return { children: result.children, split: "hub" };
|
|
1854
|
+
} catch (error) { return unsplit(`the split was refused: ${error instanceof PreEffectToolRefusal ? sanitize(error.message).slice(0, 200) : "the board refused it"}`); }
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
/** #334: the person approves a draft; it and its children leave draft and follow the normal backlog pull. */
|
|
1858
|
+
async approveIntake(by: PeerId, id: unknown): Promise<Task[]> {
|
|
1859
|
+
if (!this.personOrConductor(by)) throw new PreEffectToolRefusal("intake approval is a console command");
|
|
1860
|
+
if (typeof id !== "number" || !Number.isSafeInteger(id) || id <= 0) throw new PreEffectToolRefusal("id must be a positive task number");
|
|
1861
|
+
const family = this.intakeFamily(id);
|
|
1862
|
+
let approved: Task[];
|
|
1863
|
+
try { approved = this.d.board.approveDrafts(family.map(task => task.id), by, by === USER ? "intake approved by the person" : `intake approved by the conductor ${by}`); }
|
|
1864
|
+
catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
|
|
1865
|
+
for (const task of approved) this.incomingBacklog.add(task.id);
|
|
1866
|
+
this.releaseNotice(`intake draft #${id} approved: ${approved.length} backlog card${approved.length === 1 ? "" : "s"}`);
|
|
1867
|
+
return approved;
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
/** #334: the person discards a draft; it and its children are cancelled through `cancel`. */
|
|
1871
|
+
async discardIntake(by: PeerId, id: unknown): Promise<Task[]> {
|
|
1872
|
+
if (by !== USER) throw new PreEffectToolRefusal("intake discard is a console command");
|
|
1873
|
+
if (typeof id !== "number" || !Number.isSafeInteger(id) || id <= 0) throw new PreEffectToolRefusal("id must be a positive task number");
|
|
1874
|
+
const cancelled: Task[] = [];
|
|
1875
|
+
for (const task of this.intakeFamily(id)) cancelled.push(await this.cancel(task.id, USER, { reason: "intake draft discarded by the person" }));
|
|
1876
|
+
return cancelled;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1578
1879
|
/** Release assignment changes no execution role, dependency, reservation or state. */
|
|
1579
1880
|
async assignRelease(by: PeerId, input: { id: number; revision: number; release: number | null; reason: string }): Promise<Task> {
|
|
1580
1881
|
this.plannerWriteAuthority(by);
|
|
@@ -1724,12 +2025,14 @@ export class Tasks {
|
|
|
1724
2025
|
return updated; // a failed readback may follow a durable release write
|
|
1725
2026
|
}
|
|
1726
2027
|
|
|
1727
|
-
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> {
|
|
1728
|
-
const requestCheck = this.requestCheck(by);
|
|
2028
|
+
async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; role?: unknown; refs?: TaskRefs; plan?: TaskPlan; owner?: PeerId; after?: unknown; urgent?: unknown; backlog?: unknown; draft?: unknown; goal?: unknown }, external?: GitHubSource, created?: () => void, fence?: () => string | undefined): Promise<Task> {
|
|
2029
|
+
const requestCheck = this.requestCheck(by, fence);
|
|
2030
|
+
requireLocalTaskPeer(input.owner);
|
|
1729
2031
|
external = external ? { ...external } : undefined;
|
|
1730
|
-
if (external && (by
|
|
2032
|
+
if (external && (!this.personOrConductor(by) || input.backlog !== true)) throw new PreEffectToolRefusal("external intake requires person-owned backlog staging");
|
|
1731
2033
|
if (input.backlog !== undefined && typeof input.backlog !== "boolean") throw new PreEffectToolRefusal("backlog must be a boolean");
|
|
1732
2034
|
const backlog = input.backlog === true;
|
|
2035
|
+
if (input.draft !== undefined && (input.draft !== true || !backlog || by !== USER)) throw new PreEffectToolRefusal("only the person's intake stages a backlog draft");
|
|
1733
2036
|
const claiming = !backlog && input.owner === by;
|
|
1734
2037
|
if (claiming) {
|
|
1735
2038
|
this.acceptanceCapacity({ id: -1, owner: by, state: "proposed" });
|
|
@@ -1799,7 +2102,7 @@ export class Tasks {
|
|
|
1799
2102
|
this.warmRepository({ refs: draft.refs, plan });
|
|
1800
2103
|
const repositoryMismatch = !this.isPii({ signals }) && this.repositories.mismatch(draft.refs, plan);
|
|
1801
2104
|
requestCheck();
|
|
1802
|
-
let task = this.d.board.propose(by, { ...draft, plan, ...(external ? { external } : {}), ...(deps.length ? { deps } : {}), ...(reserved ? { reserved } : {}), signals, ...(backlog ? { backlog: true } : {}) });
|
|
2105
|
+
let task = this.d.board.propose(by, { ...draft, plan, ...(external ? { external } : {}), ...(deps.length ? { deps } : {}), ...(reserved ? { reserved } : {}), signals, ...(backlog ? { backlog: true } : {}), ...(input.draft === true ? { draft: true } : {}) });
|
|
1803
2106
|
if (repositoryMismatch) this.releaseNotice(`task #${task.id}: refs and plan name different repositories; check refs.repo before editing`);
|
|
1804
2107
|
if (this.d.coordination) this.coordinationModes.set(task.id, this.d.coordination());
|
|
1805
2108
|
created?.();
|
|
@@ -1869,6 +2172,7 @@ export class Tasks {
|
|
|
1869
2172
|
if (!Number.isSafeInteger(n) || n <= 0) throw new PreEffectToolRefusal("id must be a positive task number");
|
|
1870
2173
|
const task = this.d.board.get(n);
|
|
1871
2174
|
if (!task) throw new PreEffectToolRefusal(`no task #${n}`);
|
|
2175
|
+
if (pull && task.draft) throw new PreEffectToolRefusal(`task #${n} is an intake draft; approve it with ahub intake approve ${n} first`);
|
|
1872
2176
|
if (task.state !== (pull ? "backlog" : "proposed") || task.owner) throw new PreEffectToolRefusal(`task #${n}: ${pull ? "only backlog may be pulled" : "only ownerless proposed work may return to backlog"}`);
|
|
1873
2177
|
if (!pull && taskHasStarted(task)) throw new PreEffectToolRefusal(`task #${n}: started work cannot return to backlog`);
|
|
1874
2178
|
const pulled = task.history.findLast(entry => entry.event === "backlog pulled" || entry.event === "backlog returned");
|
|
@@ -1912,7 +2216,8 @@ export class Tasks {
|
|
|
1912
2216
|
return t.state === "proposed" && !t.owner && !t.history.some((h) => h.owner) && t.signals.includes("pii") && last.startsWith("pii: unknown") && last !== "pii: unknown, too long";
|
|
1913
2217
|
};
|
|
1914
2218
|
const tries = (t: Task) => this.rescreens.get(t.id) ?? 0;
|
|
1915
|
-
const
|
|
2219
|
+
const rescreenMax = this.d.rescreenMax?.() ?? RESCREEN_MAX;
|
|
2220
|
+
const task = this.d.board.list("proposed").filter((t) => waiting(t) && tries(t) < rescreenMax).sort((a, b) => tries(a) - tries(b))[0];
|
|
1916
2221
|
if (!task) return;
|
|
1917
2222
|
this.rescreens.set(task.id, (this.rescreens.get(task.id) ?? 0) + 1);
|
|
1918
2223
|
const text = screenText(task);
|
|
@@ -2052,6 +2357,7 @@ export class Tasks {
|
|
|
2052
2357
|
* recipient, and carries `completedFor` so the bus replaces an earlier queued notice for the same `done` task and recipient.
|
|
2053
2358
|
*/
|
|
2054
2359
|
whileOpenFor(to: PeerId, done: number, about: number[], body: string): void {
|
|
2360
|
+
requireLocalTaskPeer(to);
|
|
2055
2361
|
const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(about[0]), taskRecipient: "owner", completedFor: done } });
|
|
2056
2362
|
this.conditional.set(env.id, { peer: to, task: about[0]!, tasks: about, states: OPEN });
|
|
2057
2363
|
if (this.conditional.size > 1024) this.conditional.delete(this.conditional.keys().next().value as string);
|
|
@@ -2059,6 +2365,7 @@ export class Tasks {
|
|
|
2059
2365
|
}
|
|
2060
2366
|
|
|
2061
2367
|
whileOpen(to: PeerId, task: number, body: string, states: Task["state"][] = OPEN): void {
|
|
2368
|
+
requireLocalTaskPeer(to);
|
|
2062
2369
|
const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(task), taskRecipient: "owner" } });
|
|
2063
2370
|
this.conditional.set(env.id, { peer: to, task, states });
|
|
2064
2371
|
if (this.conditional.size > 1024) this.conditional.delete(this.conditional.keys().next().value as string);
|
|
@@ -2197,6 +2504,7 @@ export class Tasks {
|
|
|
2197
2504
|
* Only when some other peer can take a task: with nobody to hand it to, it stays with its owner.
|
|
2198
2505
|
*/
|
|
2199
2506
|
async releaseFromGone(peer: PeerId, minutes: number): Promise<{ id: number; title: string; to: PeerId | null }[]> {
|
|
2507
|
+
requireLocalTaskPeer(peer);
|
|
2200
2508
|
const moved: { id: number; title: string; to: PeerId | null }[] = [];
|
|
2201
2509
|
const why = `owner ${peer} offline for ${minutes} min`;
|
|
2202
2510
|
const ids = this.d.board.list().filter((t) => t.owner === peer && OPEN.includes(t.state)).map((t) => t.id);
|
|
@@ -2269,11 +2577,12 @@ export class Tasks {
|
|
|
2269
2577
|
}
|
|
2270
2578
|
}
|
|
2271
2579
|
|
|
2272
|
-
/**
|
|
2580
|
+
/** Authorized manual owner removal sticks until a later explicit assignment/reservation, across hub runs. */
|
|
2273
2581
|
private personUnassigned(task: Task): boolean {
|
|
2274
|
-
const
|
|
2582
|
+
const manual = (h: HistoryEntry) => h.by === USER || h.conductorManual === true;
|
|
2583
|
+
const removed = task.history.findLastIndex((h) => h.event === "unassigned" && manual(h));
|
|
2275
2584
|
if (removed < 0) return false;
|
|
2276
|
-
return !task.history.slice(removed + 1).some((h) => h
|
|
2585
|
+
return !task.history.slice(removed + 1).some((h) => manual(h) && ["assigned", "reassigned", "escalated", "reserved", "backlog pulled"].includes(h.event));
|
|
2277
2586
|
}
|
|
2278
2587
|
|
|
2279
2588
|
/**
|
|
@@ -2325,7 +2634,7 @@ export class Tasks {
|
|
|
2325
2634
|
}
|
|
2326
2635
|
|
|
2327
2636
|
/** `override`: a person's explicit assign, which past refusals do not block and which drops an agent's reservation (#207). */
|
|
2328
|
-
private async assignOwner(task: Task, by: PeerId, opts: { current?: () => void; candidates?: PeerId[]; namedOwner?: PeerId; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean; roster?: boolean; planner?: boolean; expectedFlowRevision?: number; refuseNamed?: "pre" | "plain"; plannerPull?: { revision: number; note: string; withheld: boolean; guard: () => void } } = {}): Promise<Task> {
|
|
2637
|
+
private async assignOwner(task: Task, by: PeerId, opts: { current?: () => void; conductorManual?: true; queueOverride?: "owner"; candidates?: PeerId[]; namedOwner?: PeerId; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean; roster?: boolean; planner?: boolean; expectedFlowRevision?: number; refuseNamed?: "pre" | "plain"; plannerPull?: { revision: number; note: string; withheld: boolean; guard: () => void } } = {}): Promise<Task> {
|
|
2329
2638
|
opts.current?.();
|
|
2330
2639
|
if (task.state === "backlog" && !opts.plannerPull) throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
|
|
2331
2640
|
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
@@ -2355,7 +2664,9 @@ export class Tasks {
|
|
|
2355
2664
|
const waits = this.waitsFor(task);
|
|
2356
2665
|
const plannedMove = opts.planner || this.plannerPullRecord(task)?.pull === true && (by === HUB || opts.roster) && (opts.reason !== "manual" || opts.roster);
|
|
2357
2666
|
const plannerFull = plannedMove ? this.plannerFull(task) : [];
|
|
2358
|
-
const a = assign(opts.plannerPull ? { ...task, state: "proposed" } : task, this.states(), this.d.routing(), { exclude: [...(opts.override ? [] : this.excluded(task)), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], excludeOwners: plannerFull, ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class, task), ...(opts.namedOwner ? { namedOwner: opts.namedOwner } : {})
|
|
2667
|
+
const a = assign(opts.plannerPull ? { ...task, state: "proposed" } : task, this.states(), this.d.routing(), { exclude: [...(opts.override ? [] : this.excluded(task)), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], excludeOwners: plannerFull, ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class, task), ...(opts.namedOwner ? { namedOwner: opts.namedOwner } : {}), // A self-claim hands over no envelope; reviewer admission remains automatic.
|
|
2668
|
+
contextOverride: !!(opts.queueOverride || opts.claim),
|
|
2669
|
+
...(opts.queueOverride || opts.claim ? { queueOverride: opts.queueOverride ?? "owner" as const } : {}) });
|
|
2359
2670
|
|
|
2360
2671
|
if (waits.length) {
|
|
2361
2672
|
if (opts.plannerPull) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
|
|
@@ -2369,14 +2680,18 @@ export class Tasks {
|
|
|
2369
2680
|
const why = a.refusals?.find(row => row.role === "owner" && row.peer === opts.namedOwner)?.reason ?? "not eligible";
|
|
2370
2681
|
throw new (opts.refuseNamed === "pre" ? PreEffectToolRefusal : Error)(`cannot assign task #${task.id} to ${opts.namedOwner}: ${why}${/offline|not attached/.test(why) ? "; assign again once it is attached" : ""}`);
|
|
2371
2682
|
}
|
|
2372
|
-
if (!task.owner && task.state === "proposed") this.reportStall(task, this.ownerStallCause(task, a));
|
|
2373
2683
|
this.releaseNotice(opts.roster
|
|
2374
2684
|
? `task ${this.publicTitle(task)}: ${task.owner} is off roster and no peer can take it; it stays with ${task.owner} in ${task.state}; assign with: ahub task assign ${task.id} <peer>`
|
|
2375
2685
|
: `task ${this.publicTitle(task)}: no peer can take it (${a.trace.filter((l) => l.includes("skipped")).length} skipped); assign with: ahub task assign ${task.id} <peer>`);
|
|
2376
2686
|
// Only a decline takes the task away from its owner; a failed console assign or escalation leaves it where it was.
|
|
2377
2687
|
if (opts.clearOnFail && task.owner) {
|
|
2378
2688
|
this.cohorts.leave(task.id);
|
|
2379
|
-
|
|
2689
|
+
task = this.d.board.update(task.id, by, "unassigned", { owner: null });
|
|
2690
|
+
}
|
|
2691
|
+
if (!task.owner && task.state === "proposed") {
|
|
2692
|
+
const cause = this.ownerStallCause(task, a, !opts.claim);
|
|
2693
|
+
// A named proposal remains bound to that target when pressure clears.
|
|
2694
|
+
this.reportStall(task, cause.kind === "no-eligible-peer" && isQueuePressureCode(cause.code) && opts.namedOwner ? { ...cause, peer: opts.namedOwner } : cause);
|
|
2380
2695
|
}
|
|
2381
2696
|
return task;
|
|
2382
2697
|
}
|
|
@@ -2387,11 +2702,12 @@ export class Tasks {
|
|
|
2387
2702
|
opts.plannerPull?.guard();
|
|
2388
2703
|
const assignmentNote = this.assignmentNote(`${opts.note ?? `to ${a.owner}`}${passed}`, a);
|
|
2389
2704
|
const binding = this.plannerBinding(task, a, !!opts.plannerPull);
|
|
2390
|
-
const assignmentExtra = { ...this.reviewMetadata(a), ...(binding ? { plannerReason: binding.plannerReason } : {}), ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) };
|
|
2705
|
+
const assignmentExtra = { ...this.reviewMetadata(a, task), ...(binding ? { plannerReason: binding.plannerReason } : {}), ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}), ...(opts.conductorManual ? { conductorManual: true as const } : {}) };
|
|
2391
2706
|
opts.current?.();
|
|
2392
2707
|
const next = opts.plannerPull
|
|
2393
2708
|
? this.d.board.pullAssigned(task.id, by, a.owner, opts.plannerPull.revision, opts.plannerPull.note, opts.plannerPull.withheld, a.reviewer ?? null, assignmentNote, assignmentExtra, task.history.length, binding)
|
|
2394
2709
|
: this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(binding ? { planner: binding.planner } : {}), ...(opts.roster && task.state === "in_progress" ? { state: "proposed" as const } : {}), ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, assignmentNote, assignmentExtra, opts.expectedFlowRevision);
|
|
2710
|
+
this.queueReviewWritten(next);
|
|
2395
2711
|
if (opts.plannerPull) this.planningRecorded({ by, task: next.id, fields: ["pull", "reserved"], revision: next.planningRevision! });
|
|
2396
2712
|
// What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
|
|
2397
2713
|
// escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
|
|
@@ -2458,11 +2774,16 @@ export class Tasks {
|
|
|
2458
2774
|
return next;
|
|
2459
2775
|
}
|
|
2460
2776
|
|
|
2461
|
-
/**
|
|
2777
|
+
/** #933: the person, or the configured conductor inside an admitted operation (`TasksDeps.operational` rechecks the role). */
|
|
2778
|
+
private personOrConductor(by: PeerId): boolean { return by === USER || this.d.operational?.(by) === true; }
|
|
2779
|
+
|
|
2780
|
+
/** Advice changes by the person or, #933, the task's own owner never change ownership, class or authority. */
|
|
2462
2781
|
setRole(by: PeerId, id: unknown, raw: unknown): Task {
|
|
2463
|
-
if (by !== USER) throw new PreEffectToolRefusal("only the person may change an existing task catalog role");
|
|
2464
2782
|
if (raw === undefined) throw new PreEffectToolRefusal("name a catalog role id or explicit null to clear it");
|
|
2465
2783
|
const task = this.need(id, false, true);
|
|
2784
|
+
if (by !== USER && task.owner !== by) throw new PreEffectToolRefusal(`task #${task.id} is not yours: an agent changes the catalog role of its own task only`);
|
|
2785
|
+
// #933: under review the role is what the reviewer reviews against; only the person changes it then.
|
|
2786
|
+
if (by !== USER && task.state === "in_review") throw new PreEffectToolRefusal(`task #${task.id} is in review: only the person changes its catalog role until the verdict`);
|
|
2466
2787
|
if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("a closed task's role is historical; propose new work instead");
|
|
2467
2788
|
let selected;
|
|
2468
2789
|
try { selected = taskRole(raw === null ? undefined : raw, task.class, this.d.catalog ?? loadRoleCatalog(this.d.cwd)); }
|
|
@@ -2472,7 +2793,7 @@ export class Tasks {
|
|
|
2472
2793
|
for (const peer of new Set([next.owner, next.state === "in_review" ? next.reviewer : null])) {
|
|
2473
2794
|
if (!peer || peer === USER) continue;
|
|
2474
2795
|
this.releaseNotice(`task ${this.publicTitle(next)}: catalog role update delivery unconfirmed`, () => this.d.bus.publish(newEnvelope(HUB,
|
|
2475
|
-
`Task #${next.id}: catalog role changed by the person. ${next.role ? this.roleGuidance(next, peer, peer === next.reviewer) : "Catalog guidance cleared; follow the task's existing plan and requirements."}`,
|
|
2796
|
+
`Task #${next.id}: catalog role changed by ${by === USER ? "the person" : by}. ${next.role ? this.roleGuidance(next, peer, peer === next.reviewer) : "Catalog guidance cleared; follow the task's existing plan and requirements."}`,
|
|
2476
2797
|
{ to: [peer], kind: "task", refs: { task: String(next.id), taskRecipient: peer === next.reviewer ? "reviewer" : "owner" }, ...(this.isPii(next) ? { private: true } : {}) })));
|
|
2477
2798
|
}
|
|
2478
2799
|
return next;
|
|
@@ -2480,6 +2801,7 @@ export class Tasks {
|
|
|
2480
2801
|
|
|
2481
2802
|
/** A self-claim has no assignment envelope; its tool result carries the same bounded guidance. */
|
|
2482
2803
|
claimRoleGuidance(task: Task, peer: PeerId): string {
|
|
2804
|
+
requireLocalTaskPeer(peer);
|
|
2483
2805
|
return task.owner === peer && task.state === "in_progress" ? this.roleGuidance(task, peer) : "";
|
|
2484
2806
|
}
|
|
2485
2807
|
|
|
@@ -2642,7 +2964,8 @@ export class Tasks {
|
|
|
2642
2964
|
try { return this.d.offerRead?.(offer.peer, offer.envelopeId) !== true; } catch { return true; }
|
|
2643
2965
|
}).map(offer => ({ ...offer }));
|
|
2644
2966
|
}
|
|
2645
|
-
|
|
2967
|
+
/** A status-only snapshot is advisory; all effect/admission callers retain fresh health by default. */
|
|
2968
|
+
stalled(projection?: { ownerIsolation: Readonly<Record<string, boolean>> }): TaskStall[] {
|
|
2646
2969
|
const revision = this.d.board.statusRevision();
|
|
2647
2970
|
if (this.stallCandidates?.revision !== revision) this.stallCandidates = { revision, rows: this.d.board.stalls().flatMap(stall => {
|
|
2648
2971
|
const task = this.d.board.get(stall.task);
|
|
@@ -2652,37 +2975,213 @@ export class Tasks {
|
|
|
2652
2975
|
return this.waitsFor(task).length ? [] : [{ stall, task, planned: this.plannerPullRecord(task)?.pull === true }];
|
|
2653
2976
|
}) };
|
|
2654
2977
|
// Eligibility inputs are live; queue journal writes alone do not re-decode board histories.
|
|
2655
|
-
const
|
|
2978
|
+
const health = this.health(projection?.ownerIsolation);
|
|
2979
|
+
const key = JSON.stringify([revision, this.states(), this.d.routing(), health, [...this.d.bus.peers.keys()].map(peer => {
|
|
2656
2980
|
const queue = this.d.bus.queueSummary(peer);
|
|
2657
2981
|
return [peer, this.wipLimit(peer), queue.heldBy, queue.needsReview];
|
|
2658
2982
|
})]);
|
|
2659
|
-
if (this.stallView?.key !== key) this.stallView = { key, stalls: this.stallCandidates.rows.filter(({ stall, task, planned }) => stall.cause.kind === "no-writes" || stall.cause.kind === "cancelled-dependency" || !(planned ? this.plannerAssignment(task, true) : assign(task, this.states(), this.d.routing(), { exclude: this.excluded(task), ...this.health() })).owner).map(({ stall }) => stall) };
|
|
2983
|
+
if (this.stallView?.key !== key) this.stallView = { key, stalls: this.stallCandidates.rows.filter(({ stall, task, planned }) => stall.cause.kind === "no-writes" || stall.cause.kind === "cancelled-dependency" || !(planned ? this.plannerAssignment(task, true, this.d.routing(), undefined, health) : assign(task, this.states(), this.d.routing(), { exclude: this.excluded(task), ...this.weights(task.class, task, health), ...(stall.cause.kind === "no-eligible-peer" && isQueuePressureCode(stall.cause.code) && stall.cause.peer ? { candidates: [stall.cause.peer], namedOwner: stall.cause.peer } : {}) })).owner).map(({ stall }) => stall) };
|
|
2660
2984
|
return structuredClone(this.stallView.stalls);
|
|
2661
2985
|
}
|
|
2986
|
+
private queuePressureKnown: boolean | undefined;
|
|
2987
|
+
private queuePressureGeneration = 0;
|
|
2988
|
+
private queuePressureQueued = false;
|
|
2989
|
+
private queuePressureRunning = false;
|
|
2990
|
+
private queuePressureDirty = false;
|
|
2991
|
+
private queuePressureStopped = false;
|
|
2992
|
+
private queuePressurePass?: QueuePressurePass;
|
|
2993
|
+
private queuePressureContinuation?: ReturnType<typeof setImmediate>;
|
|
2994
|
+
|
|
2995
|
+
private markQueuePressureCandidate(): void {
|
|
2996
|
+
this.queuePressureKnown = true; this.queuePressureGeneration++;
|
|
2997
|
+
this.scheduleQueuePressureRecheck();
|
|
2998
|
+
}
|
|
2999
|
+
private queueReviewWritten(task: Task): void {
|
|
3000
|
+
if (task.state === "in_review" && task.reviewer === USER && task.history.at(-1)?.queueReviewHeld) this.markQueuePressureCandidate();
|
|
3001
|
+
}
|
|
3002
|
+
private queuePressureAdmitted(): boolean {
|
|
3003
|
+
try { return !this.queuePressureStopped && !this.d.sweepHeld?.() && !this.d.bus.isRecoveryHeld && !this.d.bus.storageError; }
|
|
3004
|
+
catch { return false; }
|
|
3005
|
+
}
|
|
3006
|
+
/** Run after the bus's synchronous queue checkpoint, coalescing notifications without polling. */
|
|
3007
|
+
scheduleQueuePressureRecheck(): void {
|
|
3008
|
+
if (this.queuePressureStopped || this.queuePressureKnown === false) return;
|
|
3009
|
+
this.queuePressureDirty = true;
|
|
3010
|
+
if (this.queuePressureQueued || this.queuePressureRunning || this.queuePressureContinuation) return;
|
|
3011
|
+
this.queuePressureQueued = true;
|
|
3012
|
+
queueMicrotask(() => {
|
|
3013
|
+
this.queuePressureQueued = false;
|
|
3014
|
+
if (!this.queuePressureAdmitted()) return;
|
|
3015
|
+
this.queuePressureDirty = false;
|
|
3016
|
+
const pass = this.queuePressurePass ??= { ownerAfter: 0, reviewAfter: 0, ownerDone: false, reviewDone: false, retained: false, generation: this.queuePressureGeneration };
|
|
3017
|
+
this.queuePressureRunning = true;
|
|
3018
|
+
let failed = false;
|
|
3019
|
+
void this.recheckQueuePressureBatch(pass).catch(() => {
|
|
3020
|
+
failed = true;
|
|
3021
|
+
// A failed/unknown metadata or authority read never creates an assignment.
|
|
3022
|
+
this.queuePressureKnown = true; this.queuePressurePass = undefined;
|
|
3023
|
+
}).finally(() => {
|
|
3024
|
+
this.queuePressureRunning = false;
|
|
3025
|
+
if (!this.queuePressureAdmitted()) { this.queuePressurePass = undefined; return; }
|
|
3026
|
+
if (failed) return;
|
|
3027
|
+
if (this.queuePressurePass && (!pass.ownerDone || !pass.reviewDone)) {
|
|
3028
|
+
this.queuePressureContinuation = setImmediate(() => { this.queuePressureContinuation = undefined; this.scheduleQueuePressureRecheck(); });
|
|
3029
|
+
this.queuePressureContinuation.unref?.();
|
|
3030
|
+
return;
|
|
3031
|
+
}
|
|
3032
|
+
this.queuePressurePass = undefined;
|
|
3033
|
+
const changed = pass.generation !== this.queuePressureGeneration;
|
|
3034
|
+
this.queuePressureKnown = pass.retained || changed;
|
|
3035
|
+
if ((this.queuePressureDirty || changed) && this.queuePressureKnown) this.scheduleQueuePressureRecheck();
|
|
3036
|
+
});
|
|
3037
|
+
});
|
|
3038
|
+
}
|
|
3039
|
+
/** Recovery waits for queued/running host writers; a stop fences a writer already in an await. */
|
|
3040
|
+
queuePressureRechecksPending = (): number => Number(this.queuePressureQueued || this.queuePressureRunning || this.queuePressureContinuation !== undefined);
|
|
3041
|
+
stopQueuePressureRechecks(): void {
|
|
3042
|
+
this.queuePressureStopped = true; this.queuePressureDirty = false; this.queuePressureQueued = false;
|
|
3043
|
+
if (this.queuePressureContinuation) clearImmediate(this.queuePressureContinuation);
|
|
3044
|
+
this.queuePressureContinuation = undefined; this.queuePressurePass = undefined;
|
|
3045
|
+
}
|
|
3046
|
+
private ordinaryQueueProposal(stall: TaskStall, task: Task | undefined): task is Task {
|
|
3047
|
+
return !!task && task.state === "proposed" && !task.owner && !task.draft
|
|
3048
|
+
&& !this.waitsFor(task).length && !this.personUnassigned(task) && !this.plannerPullRecord(task)?.pull;
|
|
3049
|
+
}
|
|
3050
|
+
private queueReviewBinding(task: Task): string {
|
|
3051
|
+
return createHash("sha256").update(JSON.stringify([this.reviewHead(task) ?? null, task.refs.repo ?? null, task.sdlc?.repo ?? null, task.sdlc?.pr ?? null])).digest("hex");
|
|
3052
|
+
}
|
|
3053
|
+
private queueReviewHeld(task: Task): boolean {
|
|
3054
|
+
if (task.state !== "in_review" || task.reviewer !== USER || this.isPii(task) || this.manuallyNamedReviewer(task)) return false;
|
|
3055
|
+
const role = task.history[this.reviewRequestRevision(task) - 1];
|
|
3056
|
+
return role?.queueReviewHeld === true && role.queueReviewBinding === this.queueReviewBinding(task);
|
|
3057
|
+
}
|
|
3058
|
+
private async recheckQueuePressureBatch(pass: QueuePressurePass): Promise<void> {
|
|
3059
|
+
if (!this.queuePressureAdmitted()) return;
|
|
3060
|
+
if (!pass.ownerDone) {
|
|
3061
|
+
const page = this.d.board.queuePressureStallPage(pass.ownerAfter, QUEUE_RECHECK_PAGE);
|
|
3062
|
+
pass.ownerAfter = page.afterTask; pass.ownerDone = page.done;
|
|
3063
|
+
for (const stall of page.rows) {
|
|
3064
|
+
if (!this.queuePressureAdmitted()) return;
|
|
3065
|
+
const task = this.d.board.get(stall.task);
|
|
3066
|
+
if (!this.ordinaryQueueProposal(stall, task)) { this.d.board.clearStall(stall.task); continue; }
|
|
3067
|
+
// A planning revision asks for fresh admission, never erases an automatic offer.
|
|
3068
|
+
const namedOwner = stall.cause.peer;
|
|
3069
|
+
const retry = namedOwner ? { candidates: [namedOwner], namedOwner } : {};
|
|
3070
|
+
const choice = assign(task, this.states(), this.d.routing(), { exclude: this.excluded(task), waitsFor: this.waitsFor(task), ...this.weights(task.class, task), ...retry });
|
|
3071
|
+
if (choice.owner) {
|
|
3072
|
+
const current = () => { if (!this.queuePressureAdmitted()) throw new PreEffectToolRefusal("queue-pressure retry is no longer admitted"); };
|
|
3073
|
+
try {
|
|
3074
|
+
// Reserved ready work shares the existing WIP, in-flight slot and offered fences.
|
|
3075
|
+
if (task.reserved) await this.offerReady(task, "queue pressure cleared", current);
|
|
3076
|
+
else await this.assignOwner(task, HUB, { current, ...retry });
|
|
3077
|
+
} catch (error) {
|
|
3078
|
+
if (!(error instanceof AssignmentUndeliveredError) && this.queuePressureAdmitted()) pass.retained = true;
|
|
3079
|
+
}
|
|
3080
|
+
if (!this.queuePressureAdmitted()) return;
|
|
3081
|
+
} // Pure transient eligibility reads keep the origin; only a gate that owns retries replaces it.
|
|
3082
|
+
const latest = this.d.board.get(task.id);
|
|
3083
|
+
if (this.ordinaryQueueProposal(stall, latest)) {
|
|
3084
|
+
const origin = this.d.board.queuePressureStallPage(task.id - 1, 1).rows.find(row => row.task === task.id);
|
|
3085
|
+
if (this.isHeld(latest)) this.d.board.clearStall(task.id); // WIP release owns its ready-held task now.
|
|
3086
|
+
else if (origin) {
|
|
3087
|
+
this.d.board.recordStall({ ...origin, replanRevision: latest.planningRevision ?? 0 });
|
|
3088
|
+
pass.retained = true;
|
|
3089
|
+
} else {
|
|
3090
|
+
const replacing = this.d.board.stall(task.id);
|
|
3091
|
+
const selected = choice.owner;
|
|
3092
|
+
const refusal = replacing && selected ? replacing.cause.peer === selected ? replacing.cause.code : replacing.cause.refusals?.find(row => row.peer === selected)?.code : undefined;
|
|
3093
|
+
const transient = replacing && (replacing.cause.kind === "no-eligible-peer" || replacing.cause.kind === "reservation") && replacing.cause.code !== "policy" && (refusal === "offline" || refusal === "not-attached");
|
|
3094
|
+
if (transient) {
|
|
3095
|
+
// The initially eligible owner vanished during admission; a policy gate never reaches this restore.
|
|
3096
|
+
this.d.board.recordStall({ ...stall, replanRevision: latest.planningRevision ?? 0 });
|
|
3097
|
+
pass.retained = true;
|
|
3098
|
+
}
|
|
3099
|
+
} // Other replacing gates keep their stall and own any later retry.
|
|
3100
|
+
} else this.d.board.clearStall(task.id);
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
if (!pass.reviewDone && this.queuePressureAdmitted()) {
|
|
3104
|
+
const ids = this.d.board.humanReviewPage(pass.reviewAfter, QUEUE_RECHECK_PAGE);
|
|
3105
|
+
pass.reviewAfter = ids.at(-1) ?? pass.reviewAfter; pass.reviewDone = ids.length < QUEUE_RECHECK_PAGE;
|
|
3106
|
+
for (const id of ids) {
|
|
3107
|
+
if (!this.queuePressureAdmitted()) return;
|
|
3108
|
+
const task = this.d.board.get(id);
|
|
3109
|
+
if (!task || !this.queueReviewHeld(task)) continue;
|
|
3110
|
+
const reviewer = await this.recheckQueueReview(task);
|
|
3111
|
+
if (!this.queuePressureAdmitted()) return;
|
|
3112
|
+
const latest = this.d.board.get(id);
|
|
3113
|
+
if (!reviewer && latest && this.queueReviewHeld(latest)) pass.retained = true;
|
|
3114
|
+
}
|
|
3115
|
+
}
|
|
3116
|
+
}
|
|
3117
|
+
private async recheckQueueReview(before: Task): Promise<PeerId | undefined> {
|
|
3118
|
+
const revision = this.reviewRequestRevision(before), head = this.reviewHead(before), owner = before.owner;
|
|
3119
|
+
const valid = () => {
|
|
3120
|
+
if (!this.queuePressureAdmitted()) throw new PreEffectToolRefusal("queue-pressure retry is no longer admitted");
|
|
3121
|
+
const task = this.need(before.id, false, true);
|
|
3122
|
+
if (!this.queueReviewHeld(task) || this.reviewRequestRevision(task) !== revision || this.reviewHead(task) !== head || task.owner !== owner) throw new PreEffectToolRefusal("queue-held review changed");
|
|
3123
|
+
return task;
|
|
3124
|
+
};
|
|
3125
|
+
const choose = (task: Task) => assign(task, this.states(), this.d.routing(), { ...this.weights(task.class, task), candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}) });
|
|
3126
|
+
const initial = choose(valid());
|
|
3127
|
+
if (!initial.reviewer || initial.reviewer === USER || initial.reviewBlocked) return undefined;
|
|
3128
|
+
try {
|
|
3129
|
+
const admission = await this.reviewRequestAdmission(before, undefined, true, before.owner ?? USER, undefined, () => { valid(); return true; });
|
|
3130
|
+
const task = valid(), selection = choose(task), reviewer = selection.reviewer;
|
|
3131
|
+
if (!reviewer || reviewer === USER || selection.reviewBlocked) return undefined;
|
|
3132
|
+
valid();
|
|
3133
|
+
const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, this.assignmentNote("queue pressure cleared", selection), this.reviewMetadata(selection), task.history.length);
|
|
3134
|
+
this.heldReviews.delete(next.id);
|
|
3135
|
+
// The current head is unchanged; its watcher reads the live reviewer at completion.
|
|
3136
|
+
this.releaseNotice(`task #${next.id}: queued review request unavailable`, () => this.sendReview(next, reviewer, "", admission));
|
|
3137
|
+
return reviewer;
|
|
3138
|
+
} catch { return undefined; }
|
|
3139
|
+
}
|
|
3140
|
+
|
|
2662
3141
|
/** Why a proposal's named owner (a self-claim included) did not get it, from the stall its proposal recorded (#601); undefined when it did. */
|
|
2663
3142
|
namedOwnerNote(task: Task, named: PeerId | undefined, by: PeerId): string | undefined {
|
|
3143
|
+
requireLocalTaskPeer(by);
|
|
2664
3144
|
if (!named || task.owner || task.state !== "proposed") return undefined;
|
|
2665
3145
|
const stall = this.d.board.stalls().find(row => row.task === task.id);
|
|
2666
3146
|
if (!stall) return undefined;
|
|
2667
3147
|
const code = stall.cause.refusals?.find(row => row.peer === named)?.code ?? (stall.cause.peer === named ? stall.cause.code : undefined);
|
|
2668
3148
|
return `${named} was not assigned${code ? ` (${code})` : ""}: ${stall.cause.kind}; assign it again with hub_task_assign once ${named} can take it`;
|
|
2669
3149
|
}
|
|
2670
|
-
private ownerStallCause(task: Task, admission: Assignment): TaskStall["cause"] {
|
|
3150
|
+
private ownerStallCause(task: Task, admission: Assignment, automatic = true): TaskStall["cause"] {
|
|
2671
3151
|
const refusal = admission.refusals?.find(row => row.role === "owner" && row.peer === task.reserved);
|
|
2672
3152
|
// Persist the existing stall vocabulary; the assignment/explain keeps the precise instance/family refusal.
|
|
2673
|
-
const persisted = (code: NonNullable<Assignment["refusals"]>[number]["code"]): TaskStall["cause"]["code"] => code === "instance" || code === "family" ? "policy" : code;
|
|
2674
|
-
|
|
3153
|
+
const persisted = (code: NonNullable<Assignment["refusals"]>[number]["code"]): TaskStall["cause"]["code"] => code === "context-headroom" || code === "instance" || code === "family" ? "policy" : code;
|
|
3154
|
+
const pressure = automatic ? admission.refusals?.find(row => row.role === "owner" && isQueuePressureCode(row.code)) : undefined;
|
|
3155
|
+
return refusal && !(automatic && isQueuePressureCode(refusal.code)) ? { kind: "reservation", peer: refusal.peer, code: persisted(refusal.code) } : { kind: "no-eligible-peer", code: pressure ? persisted(pressure.code) : "unavailable", refusals: admission.refusals?.filter(row => row.role === "owner").map(({ peer, code }) => ({ peer, code: persisted(code) })).sort((a, b) => a.peer.localeCompare(b.peer)) ?? [] };
|
|
2675
3156
|
}
|
|
2676
3157
|
private reportStall(task: Task, cause: TaskStall["cause"], now = Date.now(), description?: string): void {
|
|
2677
3158
|
const stall: TaskStall = { task: task.id, cause, signature: JSON.stringify(cause), replanRevision: task.planningRevision ?? 0, at: now };
|
|
2678
3159
|
if (!this.d.board.recordStall(stall)) return;
|
|
3160
|
+
if (cause.kind === "no-eligible-peer" && isQueuePressureCode(cause.code)) this.markQueuePressureCandidate();
|
|
2679
3161
|
const text = description ?? `Task #${task.id} stalled: ${cause.kind}${cause.peer ? ` ${cause.peer}` : ""}${cause.dependency ? ` #${cause.dependency}` : ""} (${cause.code}); configure the runner or peer, ahub task assign ${task.id} <peer>, re-plan dependencies, or cancel it.`;
|
|
2680
3162
|
this.releaseNotice(text);
|
|
2681
3163
|
try { this.d.stallNotice?.(stall); } catch { /* advisory never changes task authority */ }
|
|
2682
3164
|
// Advice joins the planner's next delivery; it must not manufacture a turn beside a withheld backlog batch.
|
|
2683
3165
|
for (const peer of this.taskPlanner(task).filter(peer => this.d.bus.isRostered(peer))) this.releaseNotice(`task #${task.id}: stall notice unavailable`, () => this.d.tell?.(peer, noteLine(HUB, "finding", text)));
|
|
2684
3166
|
}
|
|
3167
|
+
/**
|
|
3168
|
+
* #901: the person's verdict on one declared claim of a document plan, with the model label they say produced it. Person only (an agent's word on its own claim is no verdict, and task
|
|
3169
|
+
* approval never supplies one). The claim is named by its 1-based slot in the task's current plan; its text is not stored, sent or echoed, so a PII task records the same counters and
|
|
3170
|
+
* its history entry stays a stub. The entry is not a transition and moves nothing; a later verdict for the same slot supersedes the earlier one (the scorecard reads the last).
|
|
3171
|
+
*/
|
|
3172
|
+
recordClaimVerdict(by: PeerId, id: unknown, input: unknown): Task {
|
|
3173
|
+
if (by !== USER) throw new Error("only the console user may record a claim verdict");
|
|
3174
|
+
if (this.d.cancellationHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) throw new Error("recovery or shutdown is holding task writes");
|
|
3175
|
+
const task = this.need(id);
|
|
3176
|
+
const claims = task.plan?.claims?.length ?? 0;
|
|
3177
|
+
if (!claims) throw new Error(`task #${task.id} declares no plan claims: add them to its plan first`);
|
|
3178
|
+
const claim = parseClaimVerdict(input, claims);
|
|
3179
|
+
// Bound to the claim list it evaluated: a hash of the plan's claims (never their text), so a later replacement or reorder of the list leaves this verdict naming no claim of the new list.
|
|
3180
|
+
return this.d.board.update(task.id, USER, "claim verdict", {}, undefined, { claim: { ...claim, list: claimListId(task.plan?.claims) } }); // no note: nothing a person typed beyond the closed fields is kept
|
|
3181
|
+
}
|
|
3182
|
+
|
|
2685
3183
|
async cancel(id: unknown, by: PeerId, input: { reason: string; supersededBy?: string; expectedFlowRevision?: number }): Promise<Task> {
|
|
3184
|
+
requireLocalTaskPeer(by);
|
|
2686
3185
|
if (typeof input.reason !== "string" || !input.reason.trim() || Buffer.byteLength(input.reason) > 4000 || input.supersededBy !== undefined && (typeof input.supersededBy !== "string" || !input.supersededBy.trim() || Buffer.byteLength(input.supersededBy) > 1000)) throw new PreEffectToolRefusal("cancellation needs a bounded reason and supersession reference");
|
|
2687
3186
|
const validate = () => {
|
|
2688
3187
|
if (this.d.cancellationHeld?.() || this.d.bus.isRecoveryHeld) throw new PreEffectToolRefusal("recovery or shutdown is holding cancellation");
|
|
@@ -2705,6 +3204,7 @@ export class Tasks {
|
|
|
2705
3204
|
if (task.history.length !== revision) throw new PreEffectToolRefusal("task flow revision changed");
|
|
2706
3205
|
const cancelled = { reason: input.reason.trim(), ...(input.supersededBy ? { supersededBy: input.supersededBy.trim() } : {}), flowRevision: revision + 1, ...(withheld ? { withheld: true as const } : {}) };
|
|
2707
3206
|
const capacity = this.capacityBefore(task);
|
|
3207
|
+
this.ciWatchers.cancel(task.id); this.heldReviews.delete(task.id);
|
|
2708
3208
|
const next = this.d.board.update(task.id, by, "cancelled", { state: "cancelled", reserved: null, cancelled }, cancelled.reason, { ...(withheld ? { withheld: true as const } : {}) }, revision);
|
|
2709
3209
|
this.writePlannerNotices(next, capacity);
|
|
2710
3210
|
this.releaseNotice(`task #${task.id}: cancellation cohort cleanup unavailable`, () => this.cohorts.leave(task.id));
|
|
@@ -2729,15 +3229,21 @@ export class Tasks {
|
|
|
2729
3229
|
}
|
|
2730
3230
|
|
|
2731
3231
|
async releaseReady(): Promise<void> {
|
|
3232
|
+
const haveQueueOrigins = this.queuePressureKnown !== false && this.d.board.queuePressureStallPage(0, 1).afterTask > 0;
|
|
2732
3233
|
for (const t of [...this.d.board.list("proposed")].sort(this.readyOrder)) {
|
|
2733
3234
|
if (t.owner) { this.d.board.clearStall(t.id); continue; }
|
|
2734
3235
|
const cancelledDependency = t.deps?.find(id => this.d.board.get(id)?.state === "cancelled");
|
|
2735
3236
|
if (cancelledDependency) { this.reportStall(t, { kind: "cancelled-dependency", dependency: cancelledDependency, code: "cancelled" }); continue; }
|
|
2736
3237
|
const last = t.history.findLast(entry => !QUIET_EVENTS.has(entry.event))?.event, planned = this.plannerPullRecord(t)?.pull === true;
|
|
2737
3238
|
if (this.personUnassigned(t) || this.offered.has(t.id) || this.waitsFor(t).length) continue;
|
|
2738
|
-
|
|
3239
|
+
if (haveQueueOrigins) {
|
|
3240
|
+
const origin = this.d.board.queuePressureStallPage(t.id - 1, 1).rows.find(row => row.task === t.id);
|
|
3241
|
+
if (origin && this.ordinaryQueueProposal(origin, t)) { this.scheduleQueuePressureRecheck(); continue; }
|
|
3242
|
+
}
|
|
3243
|
+
const admission = planned ? this.plannerAssignment(t, true) : assign(t, this.states(), this.d.routing(), { exclude: this.excluded(t), ...this.weights(t.class, t) });
|
|
2739
3244
|
if (!admission.owner) {
|
|
2740
|
-
|
|
3245
|
+
// A diagnostic legacy pass is not evidence that an automatic proposal was attempted.
|
|
3246
|
+
this.reportStall(t, this.ownerStallCause(t, admission, this.hasPersonReservation(t)));
|
|
2741
3247
|
continue;
|
|
2742
3248
|
}
|
|
2743
3249
|
this.d.board.clearStall(t.id);
|
|
@@ -2749,7 +3255,8 @@ export class Tasks {
|
|
|
2749
3255
|
}
|
|
2750
3256
|
|
|
2751
3257
|
/** Callers hold a list read before an await: re-read, or a task the other caller offered meanwhile is offered twice. */
|
|
2752
|
-
private async offerReady(stale: Task, why: string): Promise<void> {
|
|
3258
|
+
private async offerReady(stale: Task, why: string, current?: () => void): Promise<void> {
|
|
3259
|
+
current?.();
|
|
2753
3260
|
let offering = false, undelivered: AssignmentUndeliveredError | undefined, slot: PeerId | undefined;
|
|
2754
3261
|
try {
|
|
2755
3262
|
const t = this.d.board.get(stale.id);
|
|
@@ -2766,7 +3273,7 @@ export class Tasks {
|
|
|
2766
3273
|
offering = true;
|
|
2767
3274
|
const ready = t.history.at(-1)?.event === "ready" ? t : this.d.board.update(t.id, HUB, "ready", {}, why);
|
|
2768
3275
|
this.releaseNotice(`task ${this.publicTitle(ready)} is ready: what it waited for is approved`);
|
|
2769
|
-
await this.assignOwner(ready, HUB, planned ? { candidates: [selected!], namedOwner: selected!, planner: true } : {});
|
|
3276
|
+
await this.assignOwner(ready, HUB, { current, ...(planned ? { candidates: [selected!], namedOwner: selected!, planner: true } : {}) });
|
|
2770
3277
|
} catch (e) {
|
|
2771
3278
|
// A saved move whose delivery failed is not "could not be assigned" (#297); the console notice was printed
|
|
2772
3279
|
// where the error was thrown. The offer stays consumed by the error's own evidence, without the readback.
|
|
@@ -2786,6 +3293,7 @@ export class Tasks {
|
|
|
2786
3293
|
}
|
|
2787
3294
|
|
|
2788
3295
|
private mine(task: Task, by: PeerId, role: "owner" | "reviewer", preEffect = false): void {
|
|
3296
|
+
requireLocalTaskPeer(by);
|
|
2789
3297
|
if (by !== USER && task[role] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: only its ${role} (${task[role] ?? "none"}) or the console user can do that`);
|
|
2790
3298
|
}
|
|
2791
3299
|
|
|
@@ -2808,6 +3316,31 @@ export class Tasks {
|
|
|
2808
3316
|
return names.length ? `task #${task.id}: the title names ${names.join(", ")}; set refs.repo to that repository (owner/name) so its issue links and the lifecycle shows it` : undefined;
|
|
2809
3317
|
}
|
|
2810
3318
|
|
|
3319
|
+
/**
|
|
3320
|
+
* #798 D1: a declared path (refs, plan paths, the file part of a section anchor) inside a git submodule of this project: snapshots, facts, overlap and undo do not observe
|
|
3321
|
+
* inside a submodule. A notice, never a refusal: the task stays valid. Advice only, so a PII task gets none, a path whose name matches a PII pattern is not named, and a
|
|
3322
|
+
* task bound to another repository is judged against that one's layout, which this project's `.gitmodules` does not describe (its scope is not the project's).
|
|
3323
|
+
*/
|
|
3324
|
+
submoduleNotice(task: Task): string | undefined {
|
|
3325
|
+
if (this.isPii(task)) return undefined;
|
|
3326
|
+
let submodules: string[];
|
|
3327
|
+
try { submodules = this.d.submodules?.() ?? []; } catch { return undefined; }
|
|
3328
|
+
if (!submodules.length) return undefined;
|
|
3329
|
+
if (this.repositories.scope(task.refs) !== this.repositories.project) return undefined;
|
|
3330
|
+
const declared = [...(task.refs.paths ?? []), ...(task.plan?.paths ?? []), ...(task.plan?.sections ?? []).map(anchor => anchor.split("#")[0]!).filter(Boolean)];
|
|
3331
|
+
const inside = new Set<string>();
|
|
3332
|
+
for (const path of declared) {
|
|
3333
|
+
let local: string;
|
|
3334
|
+
try { local = this.projectPath(path); } catch { continue; }
|
|
3335
|
+
if (local === ".." || local.startsWith("../") || isAbsolute(local)) continue;
|
|
3336
|
+
const hit = submodules.find(sub => local === sub || local.startsWith(`${sub}/`));
|
|
3337
|
+
if (hit) inside.add(hit);
|
|
3338
|
+
}
|
|
3339
|
+
const named = [...inside].filter(this.nameable).sort();
|
|
3340
|
+
if (!named.length) return inside.size ? "inside a submodule (path withheld, it matches a PII pattern): facts, overlap and undo do not observe it" : undefined;
|
|
3341
|
+
return named.map(path => `inside submodule ${path}: facts, overlap and undo do not observe it`).join("\n");
|
|
3342
|
+
}
|
|
3343
|
+
|
|
2811
3344
|
/** #694: write the one issue the task itself names (the plan-review source) into its lifecycle receipt, once; never relinks. */
|
|
2812
3345
|
private linkIssue(task: Task): Task {
|
|
2813
3346
|
if (this.isPii(task) || task.sdlc?.issue) return task;
|
|
@@ -2962,7 +3495,53 @@ export class Tasks {
|
|
|
2962
3495
|
const byKind: Record<string, number> = {};
|
|
2963
3496
|
for (const stall of this.stalled()) if (visibleIds.has(stall.task)) byKind[stall.cause.kind] = (byKind[stall.cause.kind] ?? 0) + 1;
|
|
2964
3497
|
const stalls = { open: Object.values(byKind).reduce((sum, n) => sum + n, 0), byKind, doneWithoutWrites: rows.filter(task => visibleIds.has(task.id) && task.history.findLast(entry => entry.event === "done")?.noWrites).length };
|
|
2965
|
-
|
|
3498
|
+
// #481 S7: the merge-time readback the guarded merge recorded on each merged task's receipt (host-written, never a tool input), and the two series that are not made of tasks.
|
|
3499
|
+
const mergedAt: Record<number, number> = {}, ciReruns: Record<number, number | null> = {}, ciRuns: Record<number, number | null> = {}, reviews: Record<number, number | null> = {};
|
|
3500
|
+
for (const task of rows) {
|
|
3501
|
+
const merge = task.sdlc?.merge;
|
|
3502
|
+
if (!visibleIds.has(task.id) || merge?.state !== "merged") continue;
|
|
3503
|
+
const cost = cleanMergeCost(merge.cost);
|
|
3504
|
+
const at = cost?.mergedAt ?? (Number.isSafeInteger(merge.at) ? merge.at : null);
|
|
3505
|
+
if (at !== null) mergedAt[task.id] = at;
|
|
3506
|
+
if (cost) { ciReruns[task.id] = cost.ciReruns; ciRuns[task.id] = cost.ciRuns; reviews[task.id] = cost.reviews; }
|
|
3507
|
+
}
|
|
3508
|
+
return sdlcMetrics(tasks, { ...(cutoff !== undefined ? { since: cutoff } : {}), ...(this.d.sdlc?.config().bands ? { bands: this.d.sdlc.config().bands } : {}), stalls, mergedAt, ciReruns, ciRuns, reviews, external: this.externalSeries(), ...(merged ?? {}) });
|
|
3509
|
+
}
|
|
3510
|
+
|
|
3511
|
+
/** #481 S7: one point per shipped release (1 for a hotfix, 0 otherwise; a release with no recorded train is skipped, never a 0) and one per recorded security scan whose total is known. */
|
|
3512
|
+
private externalSeries(): Partial<Record<BandMetric, SeriesPoint[]>> {
|
|
3513
|
+
const rows = this.d.board.list(), privateRelease = (id: number) => rows.some(task => task.release === id && this.isPii(task));
|
|
3514
|
+
const hotfix = this.d.board.releases().filter(release => release.state === "shipped" && release.shippedAt !== null && release.train?.history.length && !privateRelease(release.id))
|
|
3515
|
+
.sort((a, b) => a.shippedAt! - b.shippedAt! || a.id - b.id).map(release => ({ value: release.train!.hotfix ? 1 : 0, task: release.id }));
|
|
3516
|
+
const config = this.d.sdlc?.config().securityScan, repo = config?.repo ?? this.d.repository;
|
|
3517
|
+
// A band needs `window` baseline points plus the newest one, and the episode walk looks back up to EPISODE_LOOKBACK more, so the series loads that many known
|
|
3518
|
+
// totals (unknown ones are filtered in SQL, never counted against the limit). The board caps one read at 1000 rows.
|
|
3519
|
+
const need = (this.d.sdlc?.config().bands?.security_open_alerts?.window ?? DEFAULT_BAND.window) + EPISODE_LOOKBACK + 2;
|
|
3520
|
+
const scans = config && typeof repo === "string" ? this.d.board.securityScans(repo, config.sources.join(","), need, true).map(scan => ({ value: scan.total!, task: scan.id })) : [];
|
|
3521
|
+
return { hotfix_rate: hotfix, security_open_alerts: scans };
|
|
3522
|
+
}
|
|
3523
|
+
|
|
3524
|
+
/**
|
|
3525
|
+
* #481 S7: the scheduled security-scan baseline. Reads the open-alert counts GitHub already holds (`security-baseline.ts`), records one row per interval and then runs the
|
|
3526
|
+
* same band path as every other metric, so a breach is one deduplicated card. Held like every host writer; a private repository identity, a mode off, no `[security_scan]`
|
|
3527
|
+
* table and a scan that is not yet due all do nothing. A source GitHub will not read (not enabled, not permitted) records an unknown total, never zero. Returns the scan id.
|
|
3528
|
+
*/
|
|
3529
|
+
async runSecurityScan(now = Date.now()): Promise<number | undefined> {
|
|
3530
|
+
const config = this.d.sdlc?.config(), scan = config?.securityScan;
|
|
3531
|
+
if (!config || config.mode === "off" || !scan) return undefined;
|
|
3532
|
+
const repo = scan.repo ?? this.d.repository;
|
|
3533
|
+
if (typeof repo !== "string" || !this.nameable(repo)) return undefined;
|
|
3534
|
+
const held = () => !!this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || !!this.d.bus.storageError;
|
|
3535
|
+
if (held()) return undefined;
|
|
3536
|
+
const sources = scan.sources.join(","), last = this.d.board.securityScans(repo, sources, 1).at(-1);
|
|
3537
|
+
if (last && now - last.at < scan.everyH * 3_600_000) return undefined;
|
|
3538
|
+
const read = await readSecurityScan(repo, scan.sources, this.d.sdlc?.gh ?? sdlcGh);
|
|
3539
|
+
const after = this.d.sdlc?.config();
|
|
3540
|
+
// The read awaited: the lifecycle, the schedule or a hold may have changed meanwhile, so nothing is written from a stale decision.
|
|
3541
|
+
if (!read || held() || !after || after.mode === "off" || JSON.stringify(after.securityScan) !== JSON.stringify(scan)) return undefined;
|
|
3542
|
+
const id = this.d.board.recordSecurityScan({ repo, sources, counts: read.counts as Record<string, number | null>, total: read.total });
|
|
3543
|
+
this.proposeFromBands();
|
|
3544
|
+
return id;
|
|
2966
3545
|
}
|
|
2967
3546
|
|
|
2968
3547
|
/**
|
|
@@ -2981,10 +3560,14 @@ export class Tasks {
|
|
|
2981
3560
|
bandsState(): { ready: boolean; signature: string } {
|
|
2982
3561
|
const config = this.d.sdlc?.config();
|
|
2983
3562
|
const configured = !!config && config.mode !== "off" && !!config.bands && Object.keys(config.bands).length > 0;
|
|
2984
|
-
|
|
3563
|
+
// The release and scan series do not move the task board's revision, so what they last held is part of the signature: a shipped release or a new scan row is a reason to run again.
|
|
3564
|
+
const external = configured ? this.externalSeries() : {};
|
|
3565
|
+
return { ready: configured && !this.d.sweepHeld?.() && !this.d.bus.isRecoveryHeld && !this.d.bus.storageError,
|
|
3566
|
+
signature: configured ? JSON.stringify([config!.mode, config!.bands, config!.securityScan ?? null, external.hotfix_rate?.length ?? 0, external.security_open_alerts?.at(-1)?.task ?? 0]) : "" };
|
|
2985
3567
|
}
|
|
2986
3568
|
|
|
2987
3569
|
proposeFromBands(by: PeerId = HUB): number[] {
|
|
3570
|
+
requireLocalTaskPeer(by);
|
|
2988
3571
|
const config = this.d.sdlc?.config();
|
|
2989
3572
|
if (!config || config.mode === "off" || !config.bands || !Object.keys(config.bands).length) return [];
|
|
2990
3573
|
if (this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) return [];
|
|
@@ -2998,22 +3581,24 @@ export class Tasks {
|
|
|
2998
3581
|
// A card counts as open while its task is not closed: the planner has not yet decided on it.
|
|
2999
3582
|
const open = new Set(bandRows.filter(row => !isClosedTaskState(row.state)).map(row => keyOf(row).split(":")[0]!));
|
|
3000
3583
|
const created: number[] = [];
|
|
3584
|
+
// Fixed words and a number: a release or a security scan is named by its id, a task by `task #id`.
|
|
3585
|
+
const subjectText = (band: BandResult, id: number) => band.subject === "release" ? `release #${id}` : band.subject === "scan" ? `security scan #${id}` : `task #${id}`;
|
|
3001
3586
|
for (const band of report.bands as BandResult[]) {
|
|
3002
3587
|
if (band.tier === "ok" || band.lastTask === null || band.episode === null) continue;
|
|
3003
3588
|
const key = `${band.metric}:#${band.episode}`;
|
|
3004
3589
|
if (band.tier === "log") {
|
|
3005
3590
|
const marker = `${key}:log`;
|
|
3006
|
-
if (!open.has(band.metric) && this.d.board.recordBandNotice(marker)) this.releaseNotice(`sdlc band: ${band.metric} is ${band.reason}; last value ${band.last} on
|
|
3591
|
+
if (!open.has(band.metric) && this.d.board.recordBandNotice(marker)) this.releaseNotice(`sdlc band: ${band.metric} is ${band.reason}; last value ${band.last} on ${subjectText(band, band.lastTask)}`);
|
|
3007
3592
|
continue;
|
|
3008
3593
|
}
|
|
3009
3594
|
if (seen.has(`${key}:propose`) || seen.has(`${key}:diagnose`) || open.has(band.metric)) continue;
|
|
3010
3595
|
const tier = band.tier === "propose" ? "propose" : "diagnose";
|
|
3011
3596
|
const full = `${key}:${tier}`;
|
|
3012
|
-
const evidence = `metric ${band.metric}; newest value ${band.last} on
|
|
3597
|
+
const evidence = `metric ${band.metric}; newest value ${band.last} on ${subjectText(band, band.lastTask)}; episode began at ${subjectText(band, band.episode)}; baseline mean ${band.mean}, deviation ${band.sd} over ${band.baseline} ${band.subject === "release" ? "releases" : band.subject === "scan" ? "scans" : "tasks"}; ${band.reason}`;
|
|
3013
3598
|
const diagnose = tier === "diagnose";
|
|
3014
3599
|
const title = diagnose ? `Diagnose SDLC metric ${band.metric}: read-only triage` : `Review SDLC metric ${band.metric}: control band breach`;
|
|
3015
3600
|
const detail = `Band breach key: ${full}\n${evidence}\n${diagnose
|
|
3016
|
-
?
|
|
3601
|
+
? `Read-only triage for the planner: inspect the recent ${band.subject === "release" ? "releases" : band.subject === "scan" ? "security scans" : "task histories"} behind this metric and report what changed. Do not edit files, branches or the board.`
|
|
3017
3602
|
: "Backlog card for the planner: decide whether this breach needs a process change. It names the metric and its evidence; it opens no GitHub issue."}`;
|
|
3018
3603
|
const draft = { title, detail, refs: {} };
|
|
3019
3604
|
const signals = this.signalsFor(draft);
|
|
@@ -3025,6 +3610,167 @@ export class Tasks {
|
|
|
3025
3610
|
return created;
|
|
3026
3611
|
}
|
|
3027
3612
|
|
|
3613
|
+
/** The open task each schedule made: a hub-written card (first history entry by the hub) whose detail starts with the schedule marker and that is not closed. */
|
|
3614
|
+
openScheduled(): Map<string, number> {
|
|
3615
|
+
const open = new Map<string, number>();
|
|
3616
|
+
for (const row of this.d.board.list()) {
|
|
3617
|
+
const key = row.history[0]?.by === HUB && !isClosedTaskState(row.state) ? scheduleKeyOf(row.detail) : undefined;
|
|
3618
|
+
if (key !== undefined && !open.has(key)) open.set(key, row.id);
|
|
3619
|
+
}
|
|
3620
|
+
return open;
|
|
3621
|
+
}
|
|
3622
|
+
|
|
3623
|
+
/** Whether a scheduled run may write now: the same holds as every host writer (stopping or recovering, held by the daemon, a storage error). */
|
|
3624
|
+
schedulesHeld(): boolean { return !!this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || !!this.d.bus.storageError; }
|
|
3625
|
+
|
|
3626
|
+
private schedulesRunning = false;
|
|
3627
|
+
/** 1 while a schedules pass is running (its PII screen and triage are awaited), so a recovery commit waits for it like it waits for `checksPending` and `autoPullsPending`: a run mid-admission would write after the commit's digest. */
|
|
3628
|
+
schedulesPending = (): number => (this.schedulesRunning ? 1 : 0);
|
|
3629
|
+
/**
|
|
3630
|
+
* One due schedule's run: the hold, the open-task rule and the proposal, shared by the timer and `ahub schedule run`, so a manual run can never do what a due
|
|
3631
|
+
* one may not. `propose` asks the fence again right before its board write, so a run admitted and then overtaken by a hold writes nothing and counts as a skip.
|
|
3632
|
+
*/
|
|
3633
|
+
private async runSchedule(store: ScheduleStore, entry: ScheduleEntry, now: number, extraHold: () => boolean, manual = false): Promise<ScheduleRunResult> {
|
|
3634
|
+
const hold = () => this.schedulesHeld() || extraHold() ? "the hub is held" : undefined;
|
|
3635
|
+
// A skipped tick is counted for the timer only: a person's refused run is not a missed period, so it never feeds the coalescing count.
|
|
3636
|
+
const skip = () => { if (!manual) store.skipped(entry.id); };
|
|
3637
|
+
if (hold()) { skip(); return { id: entry.id, outcome: "held" }; }
|
|
3638
|
+
const open = this.openScheduled().get(entry.id);
|
|
3639
|
+
if (open !== undefined) { skip(); return { id: entry.id, outcome: "waiting", task: open }; }
|
|
3640
|
+
// The PII screen and triage are awaited inside `propose`, and a hold can begin during them (shutdown, recovery, a storage error). `propose` asks this fence
|
|
3641
|
+
// again right before its board write, so a run that was admitted and then overtaken by a hold writes nothing and counts as a skip, like one held at the start.
|
|
3642
|
+
// The same fence also reads the schedule's pause generation (#335): a person who paused or resumed it after this run was admitted has changed what should happen,
|
|
3643
|
+
// so the run writes nothing. A timer run that finds it paused or resumed meanwhile is dropped (the next pass decides); a person's own run never races its own command.
|
|
3644
|
+
const admitted = store.epoch(entry.id);
|
|
3645
|
+
let overtaken = false, changed = false;
|
|
3646
|
+
const fence = () => {
|
|
3647
|
+
const reason = hold(); if (reason) overtaken = true;
|
|
3648
|
+
if (!reason && !manual && store.epoch(entry.id) !== admitted) { changed = true; return "the schedule was paused or resumed"; }
|
|
3649
|
+
return reason;
|
|
3650
|
+
};
|
|
3651
|
+
try {
|
|
3652
|
+
const task = await this.propose(HUB, { title: entry.title, detail: scheduledDetail(entry), class: entry.class, backlog: entry.queue === "backlog",
|
|
3653
|
+
...(entry.owner ? { owner: entry.owner } : {}) }, undefined, undefined, fence);
|
|
3654
|
+
store.ran(entry.id, now, task.id);
|
|
3655
|
+
return { id: entry.id, outcome: "ran", task: task.id };
|
|
3656
|
+
} catch (error) {
|
|
3657
|
+
if (changed) return { id: entry.id, outcome: "changed" };
|
|
3658
|
+
if (overtaken) { try { skip(); } catch { /* the store closed with the hub */ } return { id: entry.id, outcome: "held" }; }
|
|
3659
|
+
store.failed(entry.id, sanitize((error as Error).message));
|
|
3660
|
+
return { id: entry.id, outcome: "failed" };
|
|
3661
|
+
}
|
|
3662
|
+
}
|
|
3663
|
+
|
|
3664
|
+
/**
|
|
3665
|
+
* #512a: one pass over the machine-local schedules. A schedule is due once `every` has passed since its period began (first sight, then its last run).
|
|
3666
|
+
* A due schedule proposes at most one task per pass, through `propose` like any proposal (the PII screen, triage, routing and notices all apply), and none
|
|
3667
|
+
* while the task its previous run made is still open. While the hub is held the whole pass skips and every due schedule counts the skip, so a long hold
|
|
3668
|
+
* ends in one run per schedule, never a burst. A run restarts the period at `now`: nothing a restart or a hold missed is replayed. Returns what each due schedule did.
|
|
3669
|
+
*/
|
|
3670
|
+
async proposeFromSchedules(store: ScheduleStore, entries: readonly ScheduleEntry[], now: number, extraHold: () => boolean = () => false): Promise<ScheduleRunResult[]> {
|
|
3671
|
+
if (this.schedulesRunning) return [];
|
|
3672
|
+
this.schedulesRunning = true;
|
|
3673
|
+
const out: ScheduleRunResult[] = [];
|
|
3674
|
+
try {
|
|
3675
|
+
store.prune(entries.map(entry => entry.id));
|
|
3676
|
+
for (const entry of entries) {
|
|
3677
|
+
const row = store.ensure(entry.id, now);
|
|
3678
|
+
if (row.paused) continue; // #335: a person's pause is neither a run nor a skipped tick
|
|
3679
|
+
if (now < row.anchor + parseEvery(entry.every)) continue;
|
|
3680
|
+
out.push(await this.runSchedule(store, entry, now, extraHold));
|
|
3681
|
+
}
|
|
3682
|
+
} finally { this.schedulesRunning = false; }
|
|
3683
|
+
return out;
|
|
3684
|
+
}
|
|
3685
|
+
|
|
3686
|
+
/**
|
|
3687
|
+
* #335: `ahub schedule run <id>`. The person runs one configured schedule now, whatever its period says. It takes the same pass guard as the timer (so it never
|
|
3688
|
+
* overlaps one), the same hold, open-task and `propose` path (`runSchedule`), and restarts that schedule's period on success like a due run. A paused schedule
|
|
3689
|
+
* runs too: a pause stops the timer, not the person. Only the person may.
|
|
3690
|
+
*/
|
|
3691
|
+
async runScheduleNow(by: PeerId, store: ScheduleStore, entries: readonly ScheduleEntry[], id: unknown, now: number, extraHold: () => boolean = () => false): Promise<ScheduleRunResult & { reason?: string }> {
|
|
3692
|
+
if (!this.personOrConductor(by)) throw new PreEffectToolRefusal("schedule run is a console command");
|
|
3693
|
+
const entry = typeof id === "string" ? entries.find(candidate => candidate.id === id) : undefined;
|
|
3694
|
+
if (!entry) throw new PreEffectToolRefusal(`no schedule ${typeof id === "string" ? JSON.stringify(id.slice(0, 64)) : "named"}; ahub schedule list shows the configured ones`);
|
|
3695
|
+
if (this.schedulesRunning) throw new PreEffectToolRefusal("a schedules pass is running; try again in a moment");
|
|
3696
|
+
this.schedulesRunning = true;
|
|
3697
|
+
try {
|
|
3698
|
+
store.ensure(entry.id, now);
|
|
3699
|
+
// #933: the board-write fence asks the hold again after the PII screen, so a conductor whose role or connection ended meanwhile writes nothing.
|
|
3700
|
+
const result = await this.runSchedule(store, entry, now, () => extraHold() || !this.personOrConductor(by), true);
|
|
3701
|
+
const reason = result.outcome === "failed" ? store.get(entry.id)?.last_error ?? undefined : undefined;
|
|
3702
|
+
return reason ? { ...result, reason } : result;
|
|
3703
|
+
} finally { this.schedulesRunning = false; }
|
|
3704
|
+
}
|
|
3705
|
+
|
|
3706
|
+
/** #335: pause or resume one configured schedule. Only the person may; resuming starts a fresh period, so a pause is never replayed as a run. */
|
|
3707
|
+
setSchedulePaused(by: PeerId, store: ScheduleStore, entries: readonly ScheduleEntry[], id: unknown, paused: boolean, now: number): void {
|
|
3708
|
+
if (!this.personOrConductor(by)) throw new PreEffectToolRefusal(`schedule ${paused ? "pause" : "resume"} is a console command`);
|
|
3709
|
+
const entry = typeof id === "string" ? entries.find(candidate => candidate.id === id) : undefined;
|
|
3710
|
+
if (!entry) throw new PreEffectToolRefusal(`no schedule ${typeof id === "string" ? JSON.stringify(id.slice(0, 64)) : "named"}; ahub schedule list shows the configured ones`);
|
|
3711
|
+
store.setPaused(entry.id, paused, now);
|
|
3712
|
+
}
|
|
3713
|
+
|
|
3714
|
+
/**
|
|
3715
|
+
* #336 (#512c): one pass over the machine-local folder triggers, on the schedules timer. Each trigger's folder is read once (never watched). The first poll of a trigger
|
|
3716
|
+
* records what is already there and proposes nothing, so adding a trigger to a full inbox does not flood the board; after that a file the trigger has not recorded (by
|
|
3717
|
+
* real path and content hash) is registered as a source through `register` and proposes ONE task through `propose` as the hub, which names the source id and never the
|
|
3718
|
+
* file's name or path. A rename or a touch keeps the hash and proposes nothing. While the hub is held nothing is read or recorded, so the files wait for the next poll.
|
|
3719
|
+
* A file is recorded only after its task exists, so a failure or a hold between the two leaves it to be tried again (a crash in that window can repeat one task; it
|
|
3720
|
+
* never loses a file). At most MAX_NEW_PER_POLL files per trigger per pass.
|
|
3721
|
+
*/
|
|
3722
|
+
async runFolderTriggers(store: FolderStorePort, entries: readonly FolderEntry[], scan: (entry: FolderEntry) => FolderScan, register: (file: ScannedFile) => { id: string; private: boolean }, now: number, extraHold: () => boolean = () => false): Promise<FolderRunResult[]> {
|
|
3723
|
+
if (this.schedulesRunning) return [];
|
|
3724
|
+
this.schedulesRunning = true;
|
|
3725
|
+
const out: FolderRunResult[] = [];
|
|
3726
|
+
try {
|
|
3727
|
+
store.prune(entries.map(entry => entry.id));
|
|
3728
|
+
for (const entry of entries) out.push(await this.runFolderTrigger(store, entry, scan, register, now, extraHold));
|
|
3729
|
+
} finally { this.schedulesRunning = false; }
|
|
3730
|
+
return out;
|
|
3731
|
+
}
|
|
3732
|
+
|
|
3733
|
+
private async runFolderTrigger(store: FolderStorePort, entry: FolderEntry, scanOf: (entry: FolderEntry) => FolderScan, register: (file: ScannedFile) => { id: string; private: boolean }, now: number, extraHold: () => boolean): Promise<FolderRunResult> {
|
|
3734
|
+
const hold = () => this.schedulesHeld() || extraHold() ? "the hub is held" : undefined;
|
|
3735
|
+
if (hold()) return { id: entry.id, outcome: "held", tasks: [], files: 0 };
|
|
3736
|
+
const scan = scanOf(entry);
|
|
3737
|
+
// A refused folder never starts the trigger: when it becomes readable, what is in it then is the baseline, not news.
|
|
3738
|
+
if (scan.refused) { store.polled(entry.id, now, scan.refused); return { id: entry.id, outcome: "refused", tasks: [], files: 0, reason: scan.refused }; }
|
|
3739
|
+
// The baseline is every file already in the folder when the trigger starts. A folder too big to hash in one poll (the scan's budget, `truncated`) is baselined over
|
|
3740
|
+
// several polls: each records what it read and proposes nothing, and the poll that reaches the end of the folder completes it. Only then is a file news.
|
|
3741
|
+
if (!store.baselined(entry.id)) {
|
|
3742
|
+
store.begin(entry.id, now);
|
|
3743
|
+
for (const file of scan.files) if (!file.seen) store.record(entry.id, file, null, now);
|
|
3744
|
+
if (!scan.truncated) store.markBaselined(entry.id, now); else store.polled(entry.id, now);
|
|
3745
|
+
return { id: entry.id, outcome: "baseline", tasks: [], files: scan.files.length };
|
|
3746
|
+
}
|
|
3747
|
+
// A file the trigger already knows by content (a rename, a touch, a copy) proposes nothing, but its new path, size and mtime are recorded, so the next scan finds it with
|
|
3748
|
+
// `known` instead of hashing it again: otherwise such files would spend the hash budget on every poll and starve a genuinely new one behind them.
|
|
3749
|
+
for (const file of scan.files) if (!file.seen && !store.isNew(entry.id, file.sha256)) store.observe(entry.id, file, now);
|
|
3750
|
+
// `isNew` is asked again for each file below, not only here: two files with the same new bytes can arrive before one poll, and the first one's record must suppress the second.
|
|
3751
|
+
const fresh = scan.files.filter(file => !file.seen && store.isNew(entry.id, file.sha256));
|
|
3752
|
+
const tasks: number[] = [];
|
|
3753
|
+
let error: string | undefined;
|
|
3754
|
+
for (const file of fresh) {
|
|
3755
|
+
if (tasks.length >= MAX_NEW_PER_POLL || hold()) break;
|
|
3756
|
+
if (!store.isNew(entry.id, file.sha256)) { store.observe(entry.id, file, now); continue; } // a duplicate found inside the loop: recorded, never a second task
|
|
3757
|
+
let overtaken = false;
|
|
3758
|
+
const fence = () => { const reason = hold(); if (reason) overtaken = true; return reason; };
|
|
3759
|
+
try {
|
|
3760
|
+
const source = register(file);
|
|
3761
|
+
const task = await this.propose(HUB, { title: entry.title, detail: folderDetail(entry, source.id), class: entry.class, backlog: entry.queue === "backlog", ...(entry.owner ? { owner: entry.owner } : {}) }, undefined, undefined, fence);
|
|
3762
|
+
store.record(entry.id, file, task.id, now);
|
|
3763
|
+
tasks.push(task.id);
|
|
3764
|
+
} catch (caught) {
|
|
3765
|
+
if (overtaken) break;
|
|
3766
|
+
error = sanitize((caught as Error).message);
|
|
3767
|
+
break; // the next poll tries this file again; later files wait behind it so order is kept
|
|
3768
|
+
}
|
|
3769
|
+
}
|
|
3770
|
+
store.polled(entry.id, now, error);
|
|
3771
|
+
return { id: entry.id, outcome: error ? "failed" : tasks.length ? "ran" : "none", tasks, files: fresh.length, ...(scan.truncated ? { more: true as const } : {}), ...(error ? { reason: error } : {}) };
|
|
3772
|
+
}
|
|
3773
|
+
|
|
3028
3774
|
/** S10 read-only planner report; S7 owns rolling bands and automatic board proposals. */
|
|
3029
3775
|
async costReport(by: PeerId, repository?: unknown, scopePrs?: readonly number[]): Promise<Record<string, unknown>> {
|
|
3030
3776
|
const authority = () => {
|
|
@@ -3064,7 +3810,7 @@ export class Tasks {
|
|
|
3064
3810
|
const scoped = current.filter(task => visiblePrs.has(task.sdlc!.pr!) && !this.isPii(task));
|
|
3065
3811
|
const scopedIds = new Set(scoped.map(task => task.id));
|
|
3066
3812
|
const stalls = { noWrites: this.stalled().filter(stall => stall.cause.kind === "no-writes" && scopedIds.has(stall.task)).length, doneWithoutWrites: scoped.filter(task => task.history.findLast(entry => entry.event === "done")?.noWrites).length };
|
|
3067
|
-
return { ...report, stalls, truncated: report.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}), bands: "
|
|
3813
|
+
return { ...report, stalls, truncated: report.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}), bands: "see ahub sdlc metrics" };
|
|
3068
3814
|
}
|
|
3069
3815
|
|
|
3070
3816
|
async planReviewGap(task: Task): Promise<string | undefined> {
|
|
@@ -3139,18 +3885,21 @@ export class Tasks {
|
|
|
3139
3885
|
* and only then is the rest recorded as before. A receipt with deliverables and nothing else records just the deliverables.
|
|
3140
3886
|
*/
|
|
3141
3887
|
async recordSdlc(by: PeerId, id: unknown, input: unknown): Promise<Task> {
|
|
3888
|
+
const requestCheck = this.requestCheck(by);
|
|
3142
3889
|
if (!input || typeof input !== "object" || Array.isArray(input) || !("deliverables" in input)) return this.recordLifecycle(by, id, input);
|
|
3143
3890
|
const { deliverables, ...receipt } = input as Record<string, unknown>;
|
|
3144
3891
|
const task = this.need(id, false, true);
|
|
3145
3892
|
if (by !== USER && by !== task.owner && by !== task.reviewer) throw new PreEffectToolRefusal("only the task owner, reviewer or console user may record lifecycle evidence");
|
|
3146
3893
|
const claims = this.deliverableClaims(deliverables, task);
|
|
3147
3894
|
const records = await this.hashClaims(claims, task, true);
|
|
3895
|
+
requestCheck();
|
|
3148
3896
|
const current = this.need(id, false, true);
|
|
3149
3897
|
if (current.history.length !== task.history.length) throw new PreEffectToolRefusal("task changed while its deliverables were hashed; record them again");
|
|
3150
3898
|
return this.recordLifecycle(by, id, receipt, records);
|
|
3151
3899
|
}
|
|
3152
3900
|
|
|
3153
3901
|
recordLifecycle(by: PeerId, id: unknown, input: unknown, deliverables?: DeliverableRecord[]): Task {
|
|
3902
|
+
requireLocalTaskPeer(by);
|
|
3154
3903
|
const task = this.need(id, false, true);
|
|
3155
3904
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
3156
3905
|
const next = sdlcReceipt(input, task.sdlc, config, VERSION);
|
|
@@ -3191,6 +3940,25 @@ export class Tasks {
|
|
|
3191
3940
|
return this.d.board.list().map(t => ({ id: t.id, after: t.deps ?? [], private: this.isPii(t), receipt: t.sdlc, phase: this.isPii(t) ? undefined : this.workflowView(t), findings: this.isPii(t) ? undefined : this.reviewFindingSets(t), plan: config.mode === "off" || this.isPii(t) ? undefined : { paths: this.planPaths(t), revision: t.history.findLast(h => h.planRevision)?.planRevision?.revision ?? 0, fingerprint: planFingerprint(t.plan ?? {}), accepted: this.validPlanDepartures(t) } }));
|
|
3192
3941
|
}
|
|
3193
3942
|
|
|
3943
|
+
/**
|
|
3944
|
+
* The board links one `readMergeQueue` read used for a subject (#491): the subject, the holders of the PRs in the open set it read (repository names compare without
|
|
3945
|
+
* case, as the queue does), and the tasks those holders and the subject depend on. Nothing else reaches the queue: a proposal, a plan or finding elsewhere, a holder
|
|
3946
|
+
* of a closed or merged PR that nobody depends on, or a receipt in another repository cannot change what it saw. Pass the queue the decision read: its rows are the
|
|
3947
|
+
* open set. Without one (the read failed) every link counts.
|
|
3948
|
+
*/
|
|
3949
|
+
private queueLinks(links: DeliveryTask[], subject: DeliveryTask, queue: MergeQueue | undefined): DeliveryTask[] {
|
|
3950
|
+
if (!queue) return links;
|
|
3951
|
+
const repo = subject.receipt?.repo?.toLowerCase(), open = new Set(queue.rows.map(row => row.pr));
|
|
3952
|
+
// The subject's own dependencies are also reached as a holder of its open PR; naming them here keeps the fence right if that PR were ever absent from the rows.
|
|
3953
|
+
const used = new Set<number>([subject.id, ...subject.after]);
|
|
3954
|
+
for (const link of links) {
|
|
3955
|
+
if (repo === undefined || link.receipt?.repo?.toLowerCase() !== repo || link.receipt.pr === undefined || !open.has(link.receipt.pr)) continue;
|
|
3956
|
+
used.add(link.id);
|
|
3957
|
+
for (const id of link.after) used.add(id);
|
|
3958
|
+
}
|
|
3959
|
+
return links.filter(link => used.has(link.id));
|
|
3960
|
+
}
|
|
3961
|
+
|
|
3194
3962
|
async deliveryQueue(repo: string) {
|
|
3195
3963
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
3196
3964
|
const before = workflowHash(this.d.board.list().map(t => ({ id: t.id, state: t.state, after: t.deps, sdlc: t.sdlc, history: t.history.length })));
|
|
@@ -3223,8 +3991,9 @@ export class Tasks {
|
|
|
3223
3991
|
private readonly mergesInFlight = new Set<number>();
|
|
3224
3992
|
/** #786: who may ask for a guarded merge. The hub's own approval trigger acts as HUB; a peer must be the one configured conductor. */
|
|
3225
3993
|
private mergeAuthority(by: PeerId): boolean {
|
|
3994
|
+
if (isRemotePeerId(by)) return false;
|
|
3226
3995
|
if (by === USER || by === HUB) return true;
|
|
3227
|
-
const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
|
|
3996
|
+
const holders = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => !isRemotePeerId(peer) && Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
|
|
3228
3997
|
return holders.length === 1 && holders[0] === by;
|
|
3229
3998
|
}
|
|
3230
3999
|
/**
|
|
@@ -3255,14 +4024,15 @@ export class Tasks {
|
|
|
3255
4024
|
this.mergesInFlight.add(task.id);
|
|
3256
4025
|
try {
|
|
3257
4026
|
const links = this.deliveryLinks(config), subject = links.find(link => link.id === task.id)!;
|
|
3258
|
-
const queueIdentity = workflowHash({ config, links });
|
|
3259
4027
|
const outcome = await guardedMerge({ mode, subject, state: task.state, taskState: n => this.d.board.get(n)?.state, config, tasks: links, run: this.d.sdlc?.gh ?? sdlcGh, readableMetadata: this.publicMetadataText,
|
|
3260
4028
|
recordMerge: record=>this.recordMergeResult(task.id,by,record,artifactCurrent),
|
|
3261
|
-
beforeMerge:
|
|
4029
|
+
beforeMerge: queue => {
|
|
3262
4030
|
if (!this.mergeAuthority(by)) return "merge authority changed during the merge check: nothing was run";
|
|
3263
|
-
//
|
|
4031
|
+
// Only the links this queue read count: a change to one of those (a dependency artifact, an ordering edge, a relink, a new holder of an open PR) invalidates
|
|
4032
|
+
// that remote evidence, and any other task's change does not. Both sides are projected through the same queue, so a new holder of an open PR shows.
|
|
3264
4033
|
const currentConfig = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
3265
|
-
|
|
4034
|
+
const currentLinks = this.deliveryLinks(currentConfig), currentSubject = currentLinks.find(link => link.id === task.id);
|
|
4035
|
+
return currentSubject && workflowHash({ config: currentConfig, links: this.queueLinks(currentLinks, currentSubject, queue) }) === workflowHash({ config, links: this.queueLinks(links, subject, queue) })
|
|
3266
4036
|
? undefined : "merge queue inputs changed during the merge check: nothing was run";
|
|
3267
4037
|
},
|
|
3268
4038
|
// Read synchronously at dispatch, after the last PR read: the task (state, receipt, privacy, dependencies, findings), the configuration and mode in force, and whether
|
|
@@ -3270,7 +4040,7 @@ export class Tasks {
|
|
|
3270
4040
|
fresh: () => {
|
|
3271
4041
|
const now = this.d.board.get(task.id), cfg = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
3272
4042
|
const current = now && this.deliveryLinks(cfg).find(link => link.id === now.id);
|
|
3273
|
-
return now && current ? { subject: current, state: now.state, config: cfg, mode: opts.dryRun ? "dry-run" : mergeModeFor(cfg, now.sdlc?.repo), allowed: this.mergeAuthority(by) && !this.d.bus.isRecoveryHeld && !this.d.bus.storageError } : undefined;
|
|
4043
|
+
return now && current ? { subject: current, state: now.state, config: cfg, mode: opts.dryRun ? "dry-run" : mergeModeFor(cfg, now.sdlc?.repo), allowed: this.mergeAuthority(by) && !this.d.sweepHeld?.() && !this.d.bus.isRecoveryHeld && !this.d.bus.storageError } : undefined;
|
|
3274
4044
|
} });
|
|
3275
4045
|
if(mode==="on" && !outcome.ran && "refused" in outcome.decision)this.recordMergeRefusal(task.id,by,outcome.decision.refused);
|
|
3276
4046
|
if(outcome.result?.state==="merged") {
|
|
@@ -3370,6 +4140,12 @@ export class Tasks {
|
|
|
3370
4140
|
if (d.path && (!this.nameable(d.path) || matchesPolicy(this.d.cwd, d.path, this.d.piiPaths ?? [], true))) { const { path: _privatePath, ...metadata } = d; return metadata; }
|
|
3371
4141
|
return d;
|
|
3372
4142
|
});
|
|
4143
|
+
// #481 AC12: release interval, lead time from approval to release and hotfix rate, from the shipped releases' recorded history; unknown stays null (never 0).
|
|
4144
|
+
// Releases whose scope holds private work are left out, like the per-release report does.
|
|
4145
|
+
const rowsNow = this.d.board.list();
|
|
4146
|
+
const publicShipped = this.d.board.releases().filter(release => !rowsNow.some(task => task.release === release.id && this.isPii(task)));
|
|
4147
|
+
const metrics = publicShipped.some(release => release.state === "shipped") ? releaseMetrics(publicShipped, rowsNow.filter(task => !this.isPii(task))) : undefined;
|
|
4148
|
+
if (metrics) report.releaseMetrics = metrics;
|
|
3373
4149
|
const trains = this.d.board.releases().filter(release => release.train && ["active", "frozen"].includes(release.state));
|
|
3374
4150
|
if (trains.length) {
|
|
3375
4151
|
const planned = trains.map(release => {
|
|
@@ -3498,11 +4274,12 @@ export class Tasks {
|
|
|
3498
4274
|
const accepted = task.sdlc?.acceptance;
|
|
3499
4275
|
const edit = accepted && !this.isPii(task) && refresh && config.mode !== "off" ? await readSpecEdit(accepted.repo, accepted.issue, read) : undefined;
|
|
3500
4276
|
const integrity = config.mode !== "off" ? publicTestIntegrity(task.sdlc && evidence && !this.isPii(task) ? await readTestIntegrity(task.sdlc, evidence, read) : this.unknownTestIntegrity(this.isPii(task)), this.publicMetadataText) : undefined;
|
|
4277
|
+
const evalResult = task.sdlc && !this.isPii(task) ? await readEvalFor(config, evidence, task.sdlc.repo, read) : undefined;
|
|
3501
4278
|
const current = valid();
|
|
3502
4279
|
if (this.planIdentity(current) !== this.planIdentity(task) || current.history.length !== task.history.length) throw new PreEffectToolRefusal("task changed during lifecycle readback");
|
|
3503
4280
|
this.capturePlanRead(current, evidence);
|
|
3504
4281
|
return sdlcStatus(config, current.sdlc, evidence, this.workflowView(current), edit, this.isPii(current) ? [] : this.reviewFindingSets(current), current.refs.commit,
|
|
3505
|
-
config.mode !== "off" ? this.planReport(this.d.board.get(current.id)!, evidence) : undefined, integrity);
|
|
4282
|
+
config.mode !== "off" ? this.planReport(this.d.board.get(current.id)!, evidence) : undefined, integrity, evalResult);
|
|
3506
4283
|
}
|
|
3507
4284
|
|
|
3508
4285
|
/** #481 S1: only build work starts from an accepted spec. */
|
|
@@ -3529,6 +4306,7 @@ export class Tasks {
|
|
|
3529
4306
|
|
|
3530
4307
|
/** Build entry with a live readback of the spec's body edit time (#481 S1): enforce refuses, advise notifies, off does nothing. */
|
|
3531
4308
|
async specGate(by: PeerId, id: unknown): Promise<void> {
|
|
4309
|
+
requireLocalTaskPeer(by);
|
|
3532
4310
|
const task = this.need(id, false, true);
|
|
3533
4311
|
if (!this.specGated(task) || task.owner !== by && by !== USER) return; // accept reports its own refusals
|
|
3534
4312
|
const accepted = task.sdlc?.acceptance;
|
|
@@ -3539,6 +4317,7 @@ export class Tasks {
|
|
|
3539
4317
|
|
|
3540
4318
|
/** #481 S1: a build stage change by an agent re-reads the body edit time first, as accept does. */
|
|
3541
4319
|
async specStageGate(by: PeerId, id: unknown, input: unknown): Promise<void> {
|
|
4320
|
+
requireLocalTaskPeer(by);
|
|
3542
4321
|
const task = this.need(id, false, true), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
3543
4322
|
if (config.mode === "off" || by === USER || !input || typeof input !== "object" || !("stage" in input)) return;
|
|
3544
4323
|
let next: ReturnType<typeof sdlcReceipt>;
|
|
@@ -3574,6 +4353,8 @@ export class Tasks {
|
|
|
3574
4353
|
return { applicability: "unknown", redFirst: "unknown", fileState: "unknown", modifiedTests: [], blockers: [privateTask ? "test integrity: private evidence unavailable" : "test integrity: current evidence unknown"] };
|
|
3575
4354
|
}
|
|
3576
4355
|
|
|
4356
|
+
/** The eval record the last approval-time lifecycle read produced for a task; consumed by the verdict that follows it. */
|
|
4357
|
+
private readonly evalRecords = new Map<number, string>();
|
|
3577
4358
|
private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string, findings?: readonly ReviewFindingSet[]): Promise<PrEvidence | undefined> {
|
|
3578
4359
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
3579
4360
|
if (config.mode === "off") return;
|
|
@@ -3587,16 +4368,24 @@ export class Tasks {
|
|
|
3587
4368
|
};
|
|
3588
4369
|
const evidence = receipt && !this.isPii(task) ? await readSdlcPr(receipt, read, true) : undefined;
|
|
3589
4370
|
const integrity = publicTestIntegrity(receipt && evidence && !this.isPii(task) ? await readTestIntegrity(receipt, evidence, read) : this.unknownTestIntegrity(this.isPii(task)), this.publicMetadataText);
|
|
4371
|
+
const evalResult = receipt && !this.isPii(task) ? await readEvalFor(config, evidence, receipt.repo, read) : undefined;
|
|
3590
4372
|
const current = this.need(task.id, false, true);
|
|
3591
4373
|
if (config.mode === "enforce" && (current.history.length !== task.history.length || current.owner !== task.owner || current.reviewer !== task.reviewer || current.state !== task.state)) throw new PreEffectToolRefusal(`task #${task.id}: changed during lifecycle readback; retry on current work`);
|
|
3592
4374
|
// Advise may continue on the current task, but evidence from its previous link cannot admit that task's review.
|
|
3593
4375
|
if (current.history.length !== task.history.length || current.owner !== task.owner || current.reviewer !== task.reviewer || current.state !== task.state || this.isPii(current) !== this.isPii(task)) return;
|
|
3594
4376
|
this.capturePlanRead(current, evidence);
|
|
3595
4377
|
const planReport = this.planReport(current, evidence);
|
|
3596
|
-
const status = sdlcStatus(config, receipt, evidence, this.workflowView(current), undefined, this.isPii(current) ? [] : findings ?? this.reviewFindingSets(current), current.refs.commit, planReport, integrity);
|
|
3597
|
-
|
|
4378
|
+
const status = sdlcStatus(config, receipt, evidence, this.workflowView(current), undefined, this.isPii(current) ? [] : findings ?? this.reviewFindingSets(current), current.refs.commit, planReport, integrity, evalResult);
|
|
4379
|
+
// #481 S5: review entry keeps the eval gate's own blockers (its check, and in enforce its evidence gaps), like the premature-review rule does for the ordinary required checks.
|
|
4380
|
+
const evalNames = evalRequiredChecks(config, evidence).filter(name => !config.requiredChecks.includes(name));
|
|
4381
|
+
const evalBlocker = (blocker: string) => evalNames.some(name => blocker.startsWith(`required check ${name}:`)) || evalGapBlockers(status.evalGate).includes(blocker);
|
|
4382
|
+
const blockers = transition === "review" ? [...new Set([...status.blockers.filter(blocker => ["lifecycle stage definition unknown", "issue identity missing", "PR identity missing", "current PR evidence unknown", "PR is not open", "local gate on current head missing or failed"].includes(blocker) || blocker.startsWith("test integrity:") || evalBlocker(blocker)), ...(status.entry ?? []), ...(status.exit ?? [])])] : status.blockers;
|
|
3598
4383
|
if (config.mode === "enforce" && blockers.length) throw new PreEffectToolRefusal(`task #${task.id}: SDLC ${transition} blocked: ${blockers.join("; ")}`);
|
|
3599
|
-
|
|
4384
|
+
// #481 S5: the recorded pass rate and suite version ride the approval note; a code-only PR, an unconfigured gate or an unreported value records nothing.
|
|
4385
|
+
const gate = status.evalGate;
|
|
4386
|
+
if (transition === "approval" && gate?.state === "passed" && gate.passRate !== undefined && gate.suite !== undefined && !this.isPii(task)) this.evalRecords.set(task.id, `Eval gate: ${gate.check} passed at ${evidence!.head.slice(0, 12)}, pass rate ${gate.passRate}, suite ${gate.suite}`);
|
|
4387
|
+
else this.evalRecords.delete(task.id);
|
|
4388
|
+
const advice = [...new Set([...blockers, ...planConformanceBlockers(planReport), ...testIntegrityBlockers(integrity), ...evalAdvice(status.evalGate)])];
|
|
3600
4389
|
if (config.mode === "advise" && advice.length) this.d.notify(`task ${this.publicTitle(task)}: SDLC ${transition} advice: ${this.isPii(task) ? "private lifecycle evidence unknown" : advice.join("; ")}`);
|
|
3601
4390
|
return evidence;
|
|
3602
4391
|
}
|
|
@@ -3680,6 +4469,7 @@ export class Tasks {
|
|
|
3680
4469
|
* announced like one found at assignment (issue #31).
|
|
3681
4470
|
*/
|
|
3682
4471
|
accept(by: PeerId, id: unknown, plan?: unknown, refs?: unknown): Task {
|
|
4472
|
+
requireLocalTaskPeer(by);
|
|
3683
4473
|
const metadata = acceptRefs(refs);
|
|
3684
4474
|
const task = this.need(id, false, true);
|
|
3685
4475
|
this.mine(task, by, "owner", true);
|
|
@@ -3825,9 +4615,10 @@ export class Tasks {
|
|
|
3825
4615
|
* Without `check` this reads the board only: no file is opened, so no row claims `changedSinceReview`. With `check` every file is measured now (one task at a time).
|
|
3826
4616
|
* Every path goes through `publicDeliverables`, so a PII task or a path under `pii.paths` shows id, hash and size only.
|
|
3827
4617
|
*/
|
|
3828
|
-
async deliverableList(opts: { task?: number; approvedOnly?: boolean; check?: boolean } = {}): Promise<DeliverableRow[]> {
|
|
4618
|
+
async deliverableList(opts: { task?: number; approvedOnly?: boolean; check?: boolean; visible?: (task: Task) => boolean } = {}): Promise<DeliverableRow[]> {
|
|
3829
4619
|
const rows: DeliverableRow[] = [];
|
|
3830
|
-
|
|
4620
|
+
// #933: `visible` drops a task before anything is read or measured (an agent's list holds only what hub_task_show shows it).
|
|
4621
|
+
const tasks = (opts.task !== undefined ? [this.d.board.get(opts.task)].filter((task): task is Task => !!task) : this.d.board.list()).filter(task => !opts.visible || opts.visible(task)).sort((a, b) => b.id - a.id);
|
|
3831
4622
|
for (const task of tasks) {
|
|
3832
4623
|
const delivered = latestDeliverables(task.history);
|
|
3833
4624
|
if (!delivered) continue;
|
|
@@ -4015,6 +4806,7 @@ export class Tasks {
|
|
|
4015
4806
|
* duplicate notice is the price of never losing one.
|
|
4016
4807
|
*/
|
|
4017
4808
|
replayHeld(peer: PeerId): void {
|
|
4809
|
+
requireLocalTaskPeer(peer);
|
|
4018
4810
|
const finished = this.d.board.list().filter((u) => (u.state === "approved" || u.state === "in_review") && u.owner && !this.isPii(u));
|
|
4019
4811
|
for (const t of this.d.board.list()) {
|
|
4020
4812
|
if (!OPEN.includes(t.state) || t.owner !== peer || this.isPii(t)) continue;
|
|
@@ -4072,6 +4864,19 @@ export class Tasks {
|
|
|
4072
4864
|
return [head, "The done counts once the files did not change between two calls and the others have stopped.", ...lines, facts?.text ?? ""].filter(Boolean).join("\n");
|
|
4073
4865
|
}
|
|
4074
4866
|
|
|
4867
|
+
/**
|
|
4868
|
+
* #901: which check key produced this result, and how many citation keys the example citation check said were unresolved. The key is the hub's own selection (`Deps.checkKey`, the lookup
|
|
4869
|
+
* `Deps.check` makes), never read from output; without that dependency it is not recorded. The count is read only from the example check's own last line (`citations: N declared keys, M missing`, scripts/check-citations.ts);
|
|
4870
|
+
* a tail without that exact line, or with a number past the bound, records no count: unknown, never zero.
|
|
4871
|
+
*/
|
|
4872
|
+
private checkProvenance(task: Task, tail: string): Pick<HistoryEntry, "checkKey" | "missingKeys"> {
|
|
4873
|
+
let key: string | undefined;
|
|
4874
|
+
try { key = this.d.checkKey?.(task.class, task); } catch { key = undefined; }
|
|
4875
|
+
const lines = tail.split("\n").map(line => line.trim()).filter(Boolean);
|
|
4876
|
+
const match = /^citations: (\d{1,6}) declared keys, (\d{1,6}) missing$/.exec(lines.at(-1) ?? "");
|
|
4877
|
+
return { ...(key ? { checkKey: key } : {}), ...(match ? { missingKeys: Number(match[2]) } : {}) };
|
|
4878
|
+
}
|
|
4879
|
+
|
|
4075
4880
|
private async finishChecked(id: number, by: PeerId, summary: string | undefined, command: string, seen: { events: number; owner: PeerId | null }, withheld: boolean): Promise<void> {
|
|
4076
4881
|
const result = await this.d.runCheck!(command).catch((e: Error) => ({ code: null, timedOut: false, interrupted: false, tail: e.message }));
|
|
4077
4882
|
this.checking.delete(id);
|
|
@@ -4095,12 +4900,12 @@ export class Tasks {
|
|
|
4095
4900
|
this.tell(task, `Task #${id}: its check passed, but the overlapping work or the files changed while it ran (turn-free integration). Call hub_task_done again.`, pii);
|
|
4096
4901
|
return;
|
|
4097
4902
|
}
|
|
4098
|
-
this.d.board.update(id, HUB, "check passed", {}, `${outcome}\n${result.tail}`.trim());
|
|
4903
|
+
this.d.board.update(id, HUB, "check passed", {}, `${outcome}\n${result.tail}`.trim(), this.checkProvenance(task, result.tail));
|
|
4099
4904
|
// The output goes to the reviewer with the done note, not into shared memory: nobody screened it.
|
|
4100
4905
|
await this.complete(this.d.board.get(id)!, by, `${summary ?? ""}\nCheck: ${outcome}`.trim(), undefined, result.tail, withheld);
|
|
4101
4906
|
return;
|
|
4102
4907
|
}
|
|
4103
|
-
this.d.board.update(id, HUB, "check failed", {}, `${outcome}\n${result.tail}`.trim());
|
|
4908
|
+
this.d.board.update(id, HUB, "check failed", {}, `${outcome}\n${result.tail}`.trim(), this.checkProvenance(task, result.tail));
|
|
4104
4909
|
this.cohorts.withdraw(id); // open again: its done intent no longer counts (issue #107)
|
|
4105
4910
|
if (task.owner) this.d.board.recordOutcome(task.owner, task.class, false);
|
|
4106
4911
|
this.contradict(task);
|
|
@@ -4137,8 +4942,10 @@ export class Tasks {
|
|
|
4137
4942
|
this.warmRepository({ ...task, refs: { ...task.refs, ...completedRefs } });
|
|
4138
4943
|
const noWrites = this.noWritesRecorded({ ...task, refs: { ...task.refs, ...completedRefs } });
|
|
4139
4944
|
const capacity = this.capacityBefore(task);
|
|
4140
|
-
const next = this.d.board.update(task.id, by, "done", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...completedRefs } }), state: reviewer ? "in_review" : "approved", ...(selection ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { ...this.planObservation(task), withheld: withheld || undefined, ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection), ...(reviewCost ? { reviewCost } : {}), ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
|
|
4945
|
+
const next = this.d.board.update(task.id, by, "done", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...completedRefs } }), state: reviewer ? "in_review" : "approved", ...(selection ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { ...this.planObservation(task), withheld: withheld || undefined, ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection, { ...task, refs: { ...task.refs, ...completedRefs } }), ...(reviewCost ? { reviewCost } : {}), ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
|
|
4946
|
+
this.queueReviewWritten(next);
|
|
4141
4947
|
this.writePlannerNotices(next, capacity, next.state === "approved");
|
|
4948
|
+
this.ciWatchers.cancel(next.id); this.heldReviews.delete(next.id); // a newer head or a move ends the old head's watcher (#785 P1b)
|
|
4142
4949
|
this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
|
|
4143
4950
|
const memorySymbols = this.completionMemorySymbols(next);
|
|
4144
4951
|
this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\nChanged-symbol memory anchors: ${memorySymbols.state === "known" ? memorySymbols.nodes.length : "unknown (no complete code facts)"}\n${summary ?? ""}`, withheld, memorySymbols);
|
|
@@ -4151,6 +4958,7 @@ export class Tasks {
|
|
|
4151
4958
|
}
|
|
4152
4959
|
else if (reviewer === USER) this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}: review it in the ahub console (Tasks, select #${next.id}, press r) or with ahub review ${next.id} approved|changes_requested [note]`);
|
|
4153
4960
|
else this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${reviewer}`, () => this.sendReview(next, reviewer, "", admission));
|
|
4961
|
+
if (reviewer && !(admission?.ready && admission.head === this.reviewHead(next))) this.watchCi(next);
|
|
4154
4962
|
return next;
|
|
4155
4963
|
}
|
|
4156
4964
|
|
|
@@ -4255,7 +5063,7 @@ export class Tasks {
|
|
|
4255
5063
|
let quietDrift = false;
|
|
4256
5064
|
const guard = () => {
|
|
4257
5065
|
if (actor !== USER && actor !== task.owner) {
|
|
4258
|
-
const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([peer]) => peer);
|
|
5066
|
+
const holders = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => !isRemotePeerId(peer) && Array.isArray(roles) && roles.includes("conductor")).map(([peer]) => peer);
|
|
4259
5067
|
if (holders.length !== 1 || holders[0] !== actor) throw new (preEffect ? PreEffectToolRefusal : Error)("review admission authority changed");
|
|
4260
5068
|
}
|
|
4261
5069
|
// #694: the board copy seen through the same staged identity link the caller's task carries.
|
|
@@ -4316,6 +5124,7 @@ export class Tasks {
|
|
|
4316
5124
|
if (mode === "enforce" && !admission?.ready) {
|
|
4317
5125
|
// Background roster/quota role changes are retained, but never invent a remotely admitted review request.
|
|
4318
5126
|
this.releaseNotice(`task ${this.publicTitle(task)}: premature review held; retry the reviewer assignment after current CI and bot replies are verified`);
|
|
5127
|
+
this.holdReview(task, reviewer);
|
|
4319
5128
|
return false;
|
|
4320
5129
|
}
|
|
4321
5130
|
|
|
@@ -4395,6 +5204,67 @@ export class Tasks {
|
|
|
4395
5204
|
return this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, ...(this.reviewHead(task) ? { commit: this.reviewHead(task)! } : {}), task: String(task.id), reviewRevision: String(this.reviewRequestRevision(task)) }, ...(this.isPii(task) ? { private: true } : {}) })).includes(reviewer);
|
|
4396
5205
|
}
|
|
4397
5206
|
|
|
5207
|
+
/** #785 P1b: remember a review the hub held as premature, so a green CI at this exact head can re-offer it once. Also watches that head. */
|
|
5208
|
+
private holdReview(task: Task, reviewer: PeerId): void {
|
|
5209
|
+
try {
|
|
5210
|
+
const head = this.reviewHead(task);
|
|
5211
|
+
if (this.isPii(task) || !head || reviewer === USER) return;
|
|
5212
|
+
this.heldReviews.set(task.id, { head, reviewer, revision: this.reviewRequestRevision(task) });
|
|
5213
|
+
this.watchCi(task);
|
|
5214
|
+
} catch { /* optional bookkeeping never fails a review path */ }
|
|
5215
|
+
}
|
|
5216
|
+
|
|
5217
|
+
/** #785 P1b AC3: start the one watcher for this task's exact head. Not for private work, an unlinked PR or a lifecycle that is off; never throws. */
|
|
5218
|
+
private watchCi(task: Task): void {
|
|
5219
|
+
try {
|
|
5220
|
+
const head = this.reviewHead(task), repo = task.sdlc?.repo, pr = task.sdlc?.pr;
|
|
5221
|
+
if (!this.d.sdlc || this.d.sdlc.config().mode === "off" || this.isPii(task) || task.state !== "in_review" || !head || !/^[0-9a-f]{40}$/.test(head) || !repo || !pr) return;
|
|
5222
|
+
this.ciWatchers.start({ id: task.id, head, repo, pr });
|
|
5223
|
+
} catch { /* a watcher that cannot start leaves the review path as it was */ }
|
|
5224
|
+
}
|
|
5225
|
+
|
|
5226
|
+
/** The watcher's own check before every read and every notice: the task must still be in review at this exact head and PR. */
|
|
5227
|
+
private ciWatchCurrent(watch: CiWatch): boolean {
|
|
5228
|
+
const task = this.d.board.get(watch.id);
|
|
5229
|
+
return !!task && task.state === "in_review" && !this.isPii(task) && this.reviewHead(task) === watch.head && task.sdlc?.repo === watch.repo && task.sdlc?.pr === watch.pr
|
|
5230
|
+
&& (this.d.sdlc?.config().mode ?? "off") !== "off";
|
|
5231
|
+
}
|
|
5232
|
+
|
|
5233
|
+
/**
|
|
5234
|
+
* #785 P1b: the result of the exact head's CI, told once to the owner and the reviewer (one ids-only task envelope each, `status` weight: `fyi` is recorded but never delivered to the peer),
|
|
5235
|
+
* then a review held as premature is offered once more after a fresh readiness read. A deadline tells the console only.
|
|
5236
|
+
*/
|
|
5237
|
+
private async ciCompleted(watch: CiWatch, outcome: CiOutcome): Promise<void> {
|
|
5238
|
+
const task = this.d.board.get(watch.id);
|
|
5239
|
+
if (!task || !this.ciWatchCurrent(watch)) return;
|
|
5240
|
+
const label = `#${task.id} head ${watch.head.slice(0, 12)}`;
|
|
5241
|
+
if (outcome === "deadline") { this.releaseNotice(`task ${label}: CI did not finish within the watch deadline; no further reads`); return; }
|
|
5242
|
+
const line = `ci_completed: ${label} ${outcome}`;
|
|
5243
|
+
for (const peer of new Set([task.owner, task.reviewer])) {
|
|
5244
|
+
if (!peer || peer === HUB) continue;
|
|
5245
|
+
if (peer === USER) { this.releaseNotice(`task ${label}: CI ${outcome} at its exact head`); continue; }
|
|
5246
|
+
this.releaseNotice(`task ${label}: could not tell ${peer} the CI result`, () => this.d.bus.publish(newEnvelope(HUB, `${line}\nRead hub_task_show for the task before acting.`, { to: [peer], kind: "task", priority: "status", refs: { task: String(task.id), commit: watch.head } })));
|
|
5247
|
+
}
|
|
5248
|
+
if (outcome === "failed") return;
|
|
5249
|
+
await this.reofferHeldReview(task, watch);
|
|
5250
|
+
}
|
|
5251
|
+
|
|
5252
|
+
/** The held review of this exact head and request, once: only while it is still the same reviewer, head and revision, and a fresh admission says it is ready. */
|
|
5253
|
+
private async reofferHeldReview(task: Task, watch: CiWatch): Promise<void> {
|
|
5254
|
+
const held = this.heldReviews.get(task.id);
|
|
5255
|
+
if (!held || held.head !== watch.head || task.reviewer !== held.reviewer || this.reviewRequestRevision(task) !== held.revision) return;
|
|
5256
|
+
this.heldReviews.delete(task.id); // once: a later hold needs a new head or a new request
|
|
5257
|
+
try {
|
|
5258
|
+
const admission = await this.reviewRequestAdmission(task, undefined, false, task.owner ?? USER);
|
|
5259
|
+
const current = this.d.board.get(task.id);
|
|
5260
|
+
if (!admission?.ready || !current || !this.ciWatchCurrent(watch) || current.reviewer !== held.reviewer || this.reviewRequestRevision(current) !== held.revision) return;
|
|
5261
|
+
this.releaseNotice(`task ${this.publicTitle(current)}: could not re-offer the held review to ${held.reviewer}`, () => this.sendReview(current, held.reviewer, "(Held earlier because exact-head CI was not green; it is green now.)", admission));
|
|
5262
|
+
} catch { /* still not ready: the hold notice already named why; a reviewer reassignment retries it */ }
|
|
5263
|
+
}
|
|
5264
|
+
|
|
5265
|
+
/** Daemon shutdown: no watcher outlives the hub. */
|
|
5266
|
+
stopCiWatchers(): void { this.ciWatchers.stopAll(); this.heldReviews.clear(); }
|
|
5267
|
+
|
|
4398
5268
|
private reviewRequestRevision(task: Task): number {
|
|
4399
5269
|
// Role changes fence older requests; bookkeeping appended after the request does not.
|
|
4400
5270
|
return task.history.findLastIndex(entry => ["done", "reviewer changed", "reviewer unavailable"].includes(entry.event) || OWNERSHIP_EVENTS.has(entry.event)) + 1;
|
|
@@ -4402,6 +5272,7 @@ export class Tasks {
|
|
|
4402
5272
|
|
|
4403
5273
|
/** Host-only admission for a queued review after asynchronous native preparation. */
|
|
4404
5274
|
reviewBindingCurrent(by: PeerId, id: number, binding: { head?: string; flowRevision?: number }): boolean {
|
|
5275
|
+
requireLocalTaskPeer(by);
|
|
4405
5276
|
const task = this.d.board.get(id);
|
|
4406
5277
|
return !!task && task.state === "in_review" && task.reviewer === by && !this.isPii(task)
|
|
4407
5278
|
&& this.reviewRequestRevision(task) === binding.flowRevision && this.reviewHead(task) === binding.head;
|
|
@@ -4409,6 +5280,7 @@ export class Tasks {
|
|
|
4409
5280
|
|
|
4410
5281
|
/** Host-only failure report, bound to the exact review request and its still-current child generation. */
|
|
4411
5282
|
async retryReviewer(id: number, failedPeer: PeerId, failure: { head?: string; flowRevision?: number; cause: "start_failed" | "quota" | "no_verdict" }, current: () => boolean, onReoffered?: () => void): Promise<Task> {
|
|
5283
|
+
requireLocalTaskPeer(failedPeer);
|
|
4412
5284
|
if (!failure || !["start_failed", "quota", "no_verdict"].includes(failure.cause) || !/^[a-z][a-z0-9-]{0,63}$/.test(failedPeer) || !Number.isSafeInteger(failure.flowRevision) || failure.flowRevision! < 1 || failure.head !== undefined && (typeof failure.head !== "string" || text(failure.head) !== failure.head || Buffer.byteLength(failure.head) > 1200 || /\p{Cc}/u.test(failure.head))) throw new PreEffectToolRefusal("invalid reviewer failure binding");
|
|
4413
5285
|
const valid = () => {
|
|
4414
5286
|
const task = this.need(id, false, true);
|
|
@@ -4427,7 +5299,8 @@ export class Tasks {
|
|
|
4427
5299
|
valid();
|
|
4428
5300
|
const note = manual ? "named reviewer recorded no verdict; the person must review"
|
|
4429
5301
|
: selection?.reviewSelection ? this.assignmentNote(`reviewer unavailable: ${failure.cause}`, selection) : "no declared reviewer pool can receive the fallback; the person must review";
|
|
4430
|
-
const next = this.d.board.update(task.id, HUB, "reviewer unavailable", { reviewer }, note, { ...this.reviewMetadata(selection), ...(reviewer === USER ? { reviewBlocked: true } : {}), reviewerFailure: { peer: failedPeer, cause: failure.cause, ...(failure.head ? { head: failure.head } : {}) } }, task.history.length);
|
|
5302
|
+
const next = this.d.board.update(task.id, HUB, "reviewer unavailable", { reviewer }, note, { ...this.reviewMetadata(selection, task), ...(reviewer === USER ? { reviewBlocked: true } : {}), reviewerFailure: { peer: failedPeer, cause: failure.cause, ...(failure.head ? { head: failure.head } : {}) } }, task.history.length);
|
|
5303
|
+
this.queueReviewWritten(next);
|
|
4431
5304
|
this.releaseNotice(`task ${this.publicTitle(next)}: former review request withdrawal incomplete`, () => this.withdrawRoleRequest(task, next, "reviewer"));
|
|
4432
5305
|
let offered = false;
|
|
4433
5306
|
if (reviewer === USER) {
|
|
@@ -4507,6 +5380,7 @@ export class Tasks {
|
|
|
4507
5380
|
}
|
|
4508
5381
|
|
|
4509
5382
|
async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown, head?: unknown, coverage?: unknown, findings?: unknown): Promise<Task> {
|
|
5383
|
+
const requestCheck = this.requestCheck(by);
|
|
4510
5384
|
const authority = by === USER ? undefined : this.d.reviewAuthority?.(by, Number(id));
|
|
4511
5385
|
let findingGuard: ((task: Task) => void) | undefined;
|
|
4512
5386
|
// #520: what the coverage was checked against, pinned through every await: the commit and the lifecycle receipt (the
|
|
@@ -4514,6 +5388,7 @@ export class Tasks {
|
|
|
4514
5388
|
let covered: { commit: string | undefined; receipt: string } | undefined;
|
|
4515
5389
|
let conformanceIdentity: string | undefined;
|
|
4516
5390
|
const valid = (): Task => {
|
|
5391
|
+
requestCheck();
|
|
4517
5392
|
const refused = authority?.();
|
|
4518
5393
|
if (refused) throw new PreEffectToolRefusal(refused); // rechecked after every await, right before each write
|
|
4519
5394
|
const t = this.need(id, false, true);
|
|
@@ -4619,7 +5494,12 @@ export class Tasks {
|
|
|
4619
5494
|
}
|
|
4620
5495
|
}
|
|
4621
5496
|
}
|
|
4622
|
-
if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") {
|
|
5497
|
+
if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") {
|
|
5498
|
+
this.evalRecords.delete(task.id);
|
|
5499
|
+
await this.lifecycleGate(task, "approval", reviewedHead, proposedSets(task)); task = valid();
|
|
5500
|
+
const record = this.evalRecords.get(task.id); this.evalRecords.delete(task.id);
|
|
5501
|
+
if (record) note = `${note ?? ""}\n${record}`.trim();
|
|
5502
|
+
}
|
|
4623
5503
|
let candidate: Extract<CandidateInput, { kind: "fail" }> | undefined;
|
|
4624
5504
|
if (verdict === "changes_requested" && !evidenceOnly && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
|
|
4625
5505
|
const input: CandidateInput = { project: this.d.store?.project ?? this.d.project, kind: "fail", approach: this.publicTitle(task), symptom: items.join("\n"),
|
|
@@ -4643,6 +5523,7 @@ export class Tasks {
|
|
|
4643
5523
|
const flag = { ...this.planObservation(task), withheld: withheld || undefined, ...(report ? { reviewFindings: report } : {}), ...(unmetCriteria.length ? { unmetCriteria } : {}), ...(boundDeliverables ? { deliverables: boundDeliverables } : {}), ...(reviewContext ? { reviewContext } : {}), ...(codeScope ? { reviewScope: "code" as const } : {}), ...(evidenceOnly ? { evidenceOnly: true as const } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
|
|
4644
5524
|
if (verdict === "approved") {
|
|
4645
5525
|
const capacity = this.capacityBefore(task);
|
|
5526
|
+
this.ciWatchers.cancel(task.id); this.heldReviews.delete(task.id);
|
|
4646
5527
|
const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0, ...(reviewedHead && task.sdlc ? { sdlc: { ...task.sdlc, reviewedHead, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), "merge") } } : {}) }, note, flag);
|
|
4647
5528
|
this.writePlannerNotices(next, capacity, next.state === "approved");
|
|
4648
5529
|
if (next.owner) {
|
|
@@ -4661,11 +5542,14 @@ export class Tasks {
|
|
|
4661
5542
|
this.autoMerge(next, this.mirrorVerdict(next, by, "approved", reviewedHead ?? next.sdlc?.reviewedHead, note, withheld));
|
|
4662
5543
|
// A proposal storage failure cannot roll back the saved verdict; another matching verdict retries it.
|
|
4663
5544
|
this.releaseNotice(`task #${next.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(next, by, parsed, withheld));
|
|
5545
|
+
this.releaseNotice(`task #${next.id}: review saved but recurring-criterion proposal unavailable`, () => this.proposeCriterionCandidates(next, by, withheld));
|
|
4664
5546
|
return next;
|
|
4665
5547
|
}
|
|
5548
|
+
this.ciWatchers.cancel(task.id); this.heldReviews.delete(task.id);
|
|
4666
5549
|
const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: evidenceOnly ? task.rejections : task.rejections + 1 }, note, flag);
|
|
4667
5550
|
void this.mirrorVerdict(rejected, by, "changes_requested", reviewedHead ?? rejected.sdlc?.reviewedHead, note, withheld);
|
|
4668
5551
|
if (!evidenceOnly) this.releaseNotice(`task #${rejected.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(rejected, by, parsed, withheld));
|
|
5552
|
+
if (!evidenceOnly) this.releaseNotice(`task #${rejected.id}: review saved but recurring-criterion proposal unavailable`, () => this.proposeCriterionCandidates(rejected, by, withheld));
|
|
4669
5553
|
if (candidate && !pii && !this.d.privateTurn?.(by) && this.candidateTextAllowed(JSON.stringify({ candidate, refs: rejected.refs, plan: rejected.plan }), rejected)) {
|
|
4670
5554
|
try { this.d.memoryCheck?.(); this.d.candidates?.put({ ...candidate, evidenceRefs: [...candidate.evidenceRefs, `review:${rejected.id}:${rejected.history.length}`] }, "public"); } catch { /* a saved review remains authoritative if the optional sidecar fails */ }
|
|
4671
5555
|
}
|
|
@@ -4705,7 +5589,7 @@ export class Tasks {
|
|
|
4705
5589
|
* the review or the task reaches it; a PII task's notice is private. With no conductor the person sees one console notice instead.
|
|
4706
5590
|
*/
|
|
4707
5591
|
private tellConductorEvidenceOnly(task: Task, reviewer: PeerId): void {
|
|
4708
|
-
const conductors = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor).filter(actor => actor !== reviewer);
|
|
5592
|
+
const conductors = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => !isRemotePeerId(peer) && Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor).filter(actor => actor !== reviewer);
|
|
4709
5593
|
const text = evidenceOnlyNotice(task.id, reviewer);
|
|
4710
5594
|
if (!conductors.length) { this.releaseNotice(text); return; }
|
|
4711
5595
|
this.releaseNotice(`task #${task.id}: could not tell the conductor a code-scope review was evidence-only`, () => {
|
|
@@ -4715,6 +5599,7 @@ export class Tasks {
|
|
|
4715
5599
|
|
|
4716
5600
|
/** Task-level escalation: the next attached peer in the class's escalate_to takes over, with the history. */
|
|
4717
5601
|
async escalate(by: PeerId, id: unknown, why = "by hand", reason: TaskMoveReason | undefined = by === HUB ? undefined : "manual", preEffect = false): Promise<Task> {
|
|
5602
|
+
requireLocalTaskPeer(by);
|
|
4718
5603
|
let task = this.need(id, true, preEffect);
|
|
4719
5604
|
const list = this.d.routing().classes[task.class]?.escalate_to ?? [];
|
|
4720
5605
|
if (task.state === "changes_requested") task = this.d.board.update(task.id, HUB, "reopened", { state: "in_progress" });
|
|
@@ -4776,8 +5661,33 @@ export class Tasks {
|
|
|
4776
5661
|
});
|
|
4777
5662
|
}
|
|
4778
5663
|
|
|
5664
|
+
/** Preserve explicit intent across a policy reload; recheck conductor authority after every await. */
|
|
5665
|
+
private explicitQueueAssignment(by: PeerId, role: Exclude<TaskAssignmentRole, "planner">, preEffect: boolean): { role: "owner" | "reviewer"; current: () => void } | undefined {
|
|
5666
|
+
if (by === USER) return { role, current: () => {} };
|
|
5667
|
+
const current = () => {
|
|
5668
|
+
const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([peer]) => peer);
|
|
5669
|
+
return holders.length === 1 && holders[0] === by && this.d.bus.isRostered(by);
|
|
5670
|
+
};
|
|
5671
|
+
if (!current()) return undefined;
|
|
5672
|
+
return { role, current: () => { if (!current()) throw new (preEffect ? PreEffectToolRefusal : Error)("only the current configured conductor may override assignment admission"); } };
|
|
5673
|
+
}
|
|
5674
|
+
|
|
5675
|
+
private configuredManualConductor(by: PeerId): boolean {
|
|
5676
|
+
if (by === USER || by === HUB || isRemotePeerId(by)) return false;
|
|
5677
|
+
const holders = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => !isRemotePeerId(peer) && Array.isArray(roles) && roles.includes("conductor")).map(([peer]) => peer);
|
|
5678
|
+
return holders.length === 1 && holders[0] === by;
|
|
5679
|
+
}
|
|
5680
|
+
private conductorManualCheck(by: PeerId, preEffect: boolean): () => void {
|
|
5681
|
+
return () => {
|
|
5682
|
+
if (!this.configuredManualConductor(by) || !this.d.bus.isRostered(by)) throw new (preEffect ? PreEffectToolRefusal : Error)("only the console user or the current configured conductor may unassign a task role");
|
|
5683
|
+
};
|
|
5684
|
+
}
|
|
5685
|
+
|
|
4779
5686
|
/** Caller authority is checked by the daemon; keep the real actor in task history. */
|
|
4780
5687
|
async assignTo(id: unknown, peer: PeerId, by: PeerId = USER, preEffect = false, role: TaskAssignmentRole = "owner", expectedFlowRevision?: number): Promise<Task> {
|
|
5688
|
+
requireLocalTaskPeer(by); requireLocalTaskPeer(peer);
|
|
5689
|
+
const conductorManual = this.configuredManualConductor(by), manualCurrent = conductorManual ? this.conductorManualCheck(by, preEffect) : undefined;
|
|
5690
|
+
manualCurrent?.();
|
|
4781
5691
|
if (role !== "owner" && role !== "reviewer" && role !== "planner") throw new (preEffect ? PreEffectToolRefusal : Error)("role must be owner, reviewer or planner");
|
|
4782
5692
|
if (role === "planner") {
|
|
4783
5693
|
if (by !== USER) throw new PreEffectToolRefusal("only the person may name a task planner");
|
|
@@ -4791,9 +5701,11 @@ export class Tasks {
|
|
|
4791
5701
|
return next;
|
|
4792
5702
|
}
|
|
4793
5703
|
if (role === "reviewer" && by !== USER) {
|
|
4794
|
-
const conductors = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
|
|
5704
|
+
const conductors = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => !isRemotePeerId(peer) && Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
|
|
4795
5705
|
if (conductors.length !== 1 || conductors[0] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)("only the console user or the configured conductor may name a reviewer");
|
|
4796
5706
|
}
|
|
5707
|
+
const queueAssignment = this.explicitQueueAssignment(by, role, preEffect);
|
|
5708
|
+
queueAssignment?.current();
|
|
4797
5709
|
const task = this.need(id, role === "owner", preEffect);
|
|
4798
5710
|
this.checkFlowRevision(task, expectedFlowRevision, preEffect);
|
|
4799
5711
|
if (!this.d.bus.isRostered(peer)) throw new (preEffect ? PreEffectToolRefusal : Error)(`${peer} is off roster; add it to the roster before assigning work`);
|
|
@@ -4801,20 +5713,23 @@ export class Tasks {
|
|
|
4801
5713
|
if (role === "reviewer") {
|
|
4802
5714
|
if (!OPEN.includes(task.state) && task.state !== "in_review") throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its reviewer can no longer change`);
|
|
4803
5715
|
this.ready(task, preEffect);
|
|
4804
|
-
const a = assign(task, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class, task) });
|
|
5716
|
+
const a = assign(task, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class, task), ...(queueAssignment ? { queueOverride: queueAssignment.role } : {}) });
|
|
4805
5717
|
if (a.reviewer !== peer) throw new (preEffect ? PreEffectToolRefusal : Error)(`cannot name ${peer} as reviewer: ${a.trace.filter(line => line.includes("reviewer") || line.includes("pii")).join("; ")}`);
|
|
4806
5718
|
const admission = task.state === "in_review" ? await this.reviewRequestAdmission(task, undefined, preEffect, by) : undefined;
|
|
4807
5719
|
// Every runtime authority check precedes the final write, including revocations during the metadata await.
|
|
5720
|
+
manualCurrent?.();
|
|
5721
|
+
queueAssignment?.current();
|
|
4808
5722
|
const current = this.need(task.id, false, preEffect);
|
|
4809
5723
|
if (current.history.length !== task.history.length) throw new (preEffect ? PreEffectToolRefusal : Error)("task changed during reviewer admission");
|
|
4810
5724
|
if (by !== USER) {
|
|
4811
|
-
const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
|
|
5725
|
+
const holders = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => !isRemotePeerId(peer) && Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
|
|
4812
5726
|
if (holders.length !== 1 || holders[0] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)("only the console user or the configured conductor may name a reviewer");
|
|
4813
5727
|
}
|
|
4814
5728
|
if (!this.d.bus.isRostered(peer)) throw new (preEffect ? PreEffectToolRefusal : Error)(`${peer} is off roster`);
|
|
4815
5729
|
this.requireCapabilities(current, peer, role, preEffect);
|
|
4816
|
-
const eligible = assign(current, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(current.owner ? { notReviewer: current.owner } : {}), ...this.weights(current.class, current) });
|
|
5730
|
+
const eligible = assign(current, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(current.owner ? { notReviewer: current.owner } : {}), ...this.weights(current.class, current), ...(queueAssignment ? { queueOverride: queueAssignment.role } : {}) });
|
|
4817
5731
|
if (eligible.reviewer !== peer) throw new (preEffect ? PreEffectToolRefusal : Error)("reviewer is no longer eligible");
|
|
5732
|
+
queueAssignment?.current();
|
|
4818
5733
|
const next = this.d.board.update(task.id, by, "reviewer changed", { reviewer: peer }, this.assignmentNote(`named reviewer ${peer}`, a), { reason: "manual" }, expectedFlowRevision);
|
|
4819
5734
|
if (next.state === "in_review" && task.reviewer && task.reviewer !== peer) {
|
|
4820
5735
|
this.withdrawRoleRequest(task, next, "reviewer");
|
|
@@ -4823,15 +5738,23 @@ export class Tasks {
|
|
|
4823
5738
|
return next;
|
|
4824
5739
|
}
|
|
4825
5740
|
// Work that waits is handed to nobody yet (issue #207): the peer becomes its reserved owner instead.
|
|
4826
|
-
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" });
|
|
4827
|
-
|
|
5741
|
+
if (!this.waitsFor(task).length) return this.assignOwner(task, by, { candidates: [peer], namedOwner: peer, event: "reassigned", reason: "manual", override: by === USER, expectedFlowRevision, refuseNamed: preEffect ? "pre" : "plain", ...(conductorManual ? { conductorManual: true as const } : {}), ...(queueAssignment ? { queueOverride: "owner" as const } : {}), current: () => { manualCurrent?.(); queueAssignment?.current(); } });
|
|
5742
|
+
manualCurrent?.();
|
|
5743
|
+
queueAssignment?.current();
|
|
5744
|
+
const next = this.d.board.update(task.id, by, "reserved", { reserved: peer }, `for ${peer}`, { reason: "manual", ...(conductorManual ? { conductorManual: true as const } : {}) }, expectedFlowRevision);
|
|
4828
5745
|
this.releaseNotice(`task ${this.publicTitle(next)} reserved for ${peer} by ${by}; it is offered to ${peer} first once what it waits for is approved`);
|
|
4829
5746
|
return next;
|
|
4830
5747
|
}
|
|
4831
5748
|
|
|
4832
|
-
/**
|
|
5749
|
+
/** Explicit removal without routing a replacement; host history preserves manual intent across role changes. */
|
|
4833
5750
|
async unassign(id: unknown, by: PeerId = USER, preEffect = false, role: TaskAssignmentRole = "owner", expectedFlowRevision?: number): Promise<Task> {
|
|
4834
|
-
|
|
5751
|
+
const authority = by === USER ? undefined : this.conductorManualCheck(by, preEffect);
|
|
5752
|
+
const current = authority && (() => {
|
|
5753
|
+
authority();
|
|
5754
|
+
if (this.d.privateTurn?.(by)) throw new (preEffect ? PreEffectToolRefusal : Error)("conductor task mutations are unavailable during a private turn");
|
|
5755
|
+
if (this.d.cancellationHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) throw new (preEffect ? PreEffectToolRefusal : Error)("recovery or shutdown is holding task writes");
|
|
5756
|
+
});
|
|
5757
|
+
current?.();
|
|
4835
5758
|
if (role !== "owner" && role !== "reviewer") throw new (preEffect ? PreEffectToolRefusal : Error)("role must be owner or reviewer");
|
|
4836
5759
|
const task = this.need(id, false, preEffect);
|
|
4837
5760
|
this.checkFlowRevision(task, expectedFlowRevision, preEffect);
|
|
@@ -4839,7 +5762,8 @@ export class Tasks {
|
|
|
4839
5762
|
if (role === "owner") {
|
|
4840
5763
|
if (!OPEN.includes(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its owner cannot be unassigned`);
|
|
4841
5764
|
if (!task.owner && !task.reserved) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: no owner or owner reservation to unassign`);
|
|
4842
|
-
|
|
5765
|
+
current?.();
|
|
5766
|
+
const next = this.d.board.update(task.id, by, "unassigned", { owner: null, reserved: null, ...(task.state === "proposed" ? {} : { state: "proposed" as const }) }, `${by === USER ? "person" : "conductor"} unassigned ${[task.owner, task.reserved].filter(Boolean).join(" and ")}`, { reason: "manual", ...(current ? { conductorManual: true as const } : {}) }, expectedFlowRevision);
|
|
4843
5767
|
this.cohorts.leave(task.id);
|
|
4844
5768
|
this.withdrawRoleRequest(task, next, "owner");
|
|
4845
5769
|
return next;
|
|
@@ -4847,7 +5771,8 @@ export class Tasks {
|
|
|
4847
5771
|
if (!OPEN.includes(task.state) && task.state !== "in_review") throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its reviewer cannot be unassigned`);
|
|
4848
5772
|
if (!task.reviewer) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: no reviewer to unassign`);
|
|
4849
5773
|
if (task.reviewer === USER && this.isPii(task)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: the human reviewer is required for PII work`);
|
|
4850
|
-
|
|
5774
|
+
current?.();
|
|
5775
|
+
const next = this.d.board.update(task.id, by, "reviewer changed", { reviewer: null }, `${by === USER ? "person" : "conductor"} unassigned reviewer ${task.reviewer}`, { reason: "manual", ...(current ? { conductorManual: true as const } : {}) }, expectedFlowRevision);
|
|
4851
5776
|
this.withdrawRoleRequest(task, next, "reviewer");
|
|
4852
5777
|
return next;
|
|
4853
5778
|
}
|
|
@@ -4857,11 +5782,13 @@ export class Tasks {
|
|
|
4857
5782
|
* the usual constraints. Tasks it was reviewing get another reviewer. Moves are reported, never taken back automatically.
|
|
4858
5783
|
*/
|
|
4859
5784
|
async reassignForPause(peer: PeerId, context: string | undefined, urgentOnly = false): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
|
|
5785
|
+
requireLocalTaskPeer(peer);
|
|
4860
5786
|
return this.reassignWork(peer, context, urgentOnly, false);
|
|
4861
5787
|
}
|
|
4862
5788
|
|
|
4863
5789
|
/** Read-only preview using the same assignment constraints as the roster relay. */
|
|
4864
5790
|
rosterPreview(peer: PeerId): { id: number; title: string; role: "owner" | "reviewer"; to: PeerId | null }[] {
|
|
5791
|
+
requireLocalTaskPeer(peer);
|
|
4865
5792
|
const routing = this.d.routing();
|
|
4866
5793
|
return this.d.board.list().filter(task => !isClosedTaskState(task.state) && (task.owner === peer || task.reviewer === peer)).map(task => {
|
|
4867
5794
|
const owner = task.owner === peer;
|
|
@@ -4875,6 +5802,7 @@ export class Tasks {
|
|
|
4875
5802
|
|
|
4876
5803
|
/** A roster removal offers work through the quota relay's constraints, preserving assignments nobody can take. */
|
|
4877
5804
|
async reassignForRoster(peer: PeerId, current?: () => void): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
|
|
5805
|
+
requireLocalTaskPeer(peer);
|
|
4878
5806
|
return this.reassignWork(peer, undefined, false, true, current);
|
|
4879
5807
|
}
|
|
4880
5808
|
|
|
@@ -4923,7 +5851,8 @@ export class Tasks {
|
|
|
4923
5851
|
continue;
|
|
4924
5852
|
}
|
|
4925
5853
|
current?.();
|
|
4926
|
-
const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, this.assignmentNote(why, a), this.reviewMetadata(a));
|
|
5854
|
+
const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, this.assignmentNote(why, a), this.reviewMetadata(a, task));
|
|
5855
|
+
this.queueReviewWritten(next);
|
|
4927
5856
|
moved.push({ id: task.id, title: this.publicTitle(task), to: reviewer, role: "reviewer" });
|
|
4928
5857
|
current?.();
|
|
4929
5858
|
if (next.state === "in_review" && reviewer && reviewer !== USER) {
|
|
@@ -4958,11 +5887,13 @@ export class Tasks {
|
|
|
4958
5887
|
|
|
4959
5888
|
/** Candidate only: the person remains the sole acceptance authority. */
|
|
4960
5889
|
async correct(by: PeerId, input: unknown): Promise<string> {
|
|
5890
|
+
const requestCheck = this.requestCheck(by);
|
|
4961
5891
|
const args = correctionInput(input);
|
|
4962
5892
|
const st = this.d.store;
|
|
4963
5893
|
if (!st || !this.d.candidates) throw new PreEffectToolRefusal("memory correction unavailable");
|
|
4964
5894
|
const screen: MemoryScreen = rows => this.d.candidateScreen?.(rows) ?? withoutPii(rows, this.d.routing(), this.d.cwd, this.d.piiPaths ?? []);
|
|
4965
5895
|
const valid = () => {
|
|
5896
|
+
requestCheck();
|
|
4966
5897
|
this.d.memoryCheck?.();
|
|
4967
5898
|
if (this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("memory correction unavailable during a private turn");
|
|
4968
5899
|
if (!this.candidateTextAllowed(JSON.stringify(args))) throw new PreEffectToolRefusal("memory correction withheld by privacy policy");
|
|
@@ -5037,6 +5968,7 @@ export class Tasks {
|
|
|
5037
5968
|
}
|
|
5038
5969
|
/** A person links a board task of this project to a long-term task (`ahub goal link`). */
|
|
5039
5970
|
linkGoal(by: PeerId, id: unknown, ref: unknown): string {
|
|
5971
|
+
requireLocalTaskPeer(by);
|
|
5040
5972
|
const task = this.d.board.get(Number(id));
|
|
5041
5973
|
if (!task) throw new Error(`no task #${String(id).slice(0, 12)}`);
|
|
5042
5974
|
const goal = this.goalFor(by, ref);
|