@staix/agent-hub 0.34.1 → 0.35.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 +47 -0
- package/README.md +35 -6
- package/docs/adding-an-ai-tool.md +2 -2
- package/docs/addons/claude-mem.md +4 -4
- package/docs/addons/open-code-review.md +1 -1
- package/docs/agent-notes/README.md +6 -0
- package/docs/agent-notes/adapters.md +9 -5
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/bus.md +1 -1
- package/docs/agent-notes/daemon.md +4 -3
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/models.md +2 -0
- package/docs/agent-notes/tasks.md +1 -1
- package/docs/agent-notes/tests.md +5 -0
- package/docs/design/adapters.md +110 -2
- package/docs/design/agent-hub-architecture.md +63 -1
- package/docs/design/agent-hub.md +6 -0
- package/docs/design/bus.md +18 -0
- package/docs/design/execution-reference.md +146 -0
- package/docs/design/task-board.md +10 -0
- package/docs/dogfood.md +33 -12
- package/docs/events.md +38 -20
- package/docs/memory.md +21 -7
- package/docs/operations.md +539 -24
- package/docs/quickstart.md +12 -4
- package/docs/research.md +71 -13
- package/docs/roles.md +1 -1
- package/docs/sdlc.md +9 -7
- package/docs/security.md +87 -11
- package/docs/smoke.md +14 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +8 -3
- package/docs/verification/2026-11-07-compat-python-startup-1248.md +51 -0
- package/docs/verification/2026-11-07-compat-receipt-harness-1248.md +105 -0
- package/docs/verification/2026-11-07-compat-receipt-producers-1248.md +96 -0
- package/docs/verification/2026-11-07-compat-workflow-1248.md +94 -0
- package/docs/verification/2026-11-07-daemon-startup-1232.md +169 -0
- package/docs/verification/2026-11-07-jcode-receipt-reconstruction-1248.md +118 -0
- package/docs/verification/2026-11-07-kimi-delayed-usage.md +34 -0
- package/docs/verification/2026-11-07-kimi-review-watch-1284.md +115 -0
- package/docs/verification/2026-11-08-agy-1.5.3.md +96 -0
- package/docs/verification/2026-11-08-agy-acp-t0.md +56 -0
- package/docs/verification/2026-11-08-hub-visual-1058.md +161 -0
- package/docs/verification/2026-11-08-jcode-0991-model-setter-1317.md +143 -0
- package/docs/verification/2026-11-08-kimi-252-model-setter-1317.md +137 -0
- package/docs/verification/2026-11-08-kimi-tui-completion-1282.md +45 -0
- package/docs/verification/2026-11-08-kimi-wire-yield-1283.md +31 -0
- package/docs/verification/2026-11-08-live-model-override-1317.md +250 -0
- package/docs/verification/2026-11-08-qwen-0269-acp.md +138 -0
- package/docs/verification/2026-11-08-qwen-0269-model-setter-1317.md +91 -0
- package/docs/verification/2026-11-09-0.35.0.md +129 -0
- package/docs/verification/2026-11-09-headless-records-1320.md +157 -0
- package/docs/verification/claude-permission-transport-native.py +10 -3
- package/docs/verification/claude-shell-wrapper-native.py +8 -8
- package/docs/verified.json +245 -15
- package/package.json +3 -3
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/kimi-hook.js +20397 -0
- package/plugins/agent-hub/kimi-review-policy-worker.js +217 -0
- package/plugins/agent-hub/server.js +2701 -664
- package/scripts/check-docs.mjs +12 -1
- package/src/adapters/acp-model-policy.ts +56 -0
- package/src/adapters/acp.ts +175 -5
- package/src/adapters/agy/entry.ts +8 -5
- package/src/adapters/agy/hooks.ts +65 -15
- package/src/adapters/channel-peer.ts +33 -1
- package/src/adapters/claude-implementer.ts +16 -3
- package/src/adapters/codex-appserver.ts +118 -8
- package/src/adapters/codex-review-isolation.ts +3 -1
- package/src/adapters/compat.ts +11 -6
- package/src/adapters/definitions/builtin-metadata.ts +8 -1
- package/src/adapters/definitions/native-cli-entry-kimi.ts +76 -7
- package/src/adapters/definitions/native-cli-preview-common.ts +2 -2
- package/src/adapters/definitions/native-cli-preview-kimi.ts +9 -2
- package/src/adapters/definitions/reviewer-variants.ts +3 -1
- package/src/adapters/kimi-review-policy-worker.js +2 -0
- package/src/adapters/kimi-review-policy-worker.ts +85 -0
- package/src/adapters/kimi-review-policy.ts +68 -0
- package/src/adapters/kimi-review.ts +41 -41
- package/src/adapters/kimi-tui-facts.ts +153 -0
- package/src/adapters/kimi-wire-usage.ts +33 -8
- package/src/adapters/local-worker.ts +59 -20
- package/src/adapters/model-token.ts +3 -0
- package/src/adapters/pi.ts +27 -5
- package/src/adapters/registry-types.ts +34 -3
- package/src/adapters/registry.ts +1 -0
- package/src/adapters/reviewer-policy-watch.ts +86 -0
- package/src/adapters/reviewer-print.ts +80 -18
- package/src/cli/agents.ts +1 -1
- package/src/cli/facts-hook.ts +4 -2
- package/src/cli/help.ts +3 -2
- package/src/cli/identity.ts +3 -0
- package/src/cli/kimi-facts-hook.ts +11 -0
- package/src/cli/kimi-plugin.ts +157 -0
- package/src/cli/main.ts +48 -9
- package/src/cli/model.ts +52 -0
- package/src/cli/output.ts +23 -1
- package/src/cli/reset.ts +1 -1
- package/src/cli/status-lines.ts +8 -4
- package/src/hub/budget.ts +1 -1
- package/src/hub/bus.ts +40 -2
- package/src/hub/code-facts.ts +10 -3
- package/src/hub/completions.ts +3 -1
- package/src/hub/config-apply.ts +2 -1
- package/src/hub/control-client.ts +1 -1
- package/src/hub/daemon.ts +1050 -106
- package/src/hub/deliverable-files.ts +38 -5
- package/src/hub/dogfood-profile.ts +1 -1
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +28 -18
- package/src/hub/execution-lineage.ts +120 -0
- package/src/hub/execution-reference.ts +36 -0
- package/src/hub/hub-tools.ts +21 -2
- package/src/hub/instances.ts +4 -3
- package/src/hub/kimi-events.ts +46 -0
- package/src/hub/kimi-usage.ts +132 -29
- package/src/hub/lifecycle.ts +31 -15
- package/src/hub/limits.ts +13 -4
- package/src/hub/manager.ts +15 -2
- package/src/hub/memory-policy.ts +2 -1
- package/src/hub/memory-sync-attestation.ts +54 -0
- package/src/hub/memory-sync-state.ts +20 -5
- package/src/hub/model-overrides.ts +115 -0
- package/src/hub/native-hook-launch.ts +12 -3
- package/src/hub/operational-view.ts +2 -2
- package/src/hub/peers.ts +27 -0
- package/src/hub/permission-mode.ts +1 -1
- package/src/hub/person-figures.ts +88 -0
- package/src/hub/report.ts +38 -4
- package/src/hub/research-dataset.ts +158 -0
- package/src/hub/research-memory.ts +68 -0
- package/src/hub/research-quality.ts +118 -0
- package/src/hub/research-record.ts +279 -0
- package/src/hub/research.ts +126 -27
- package/src/hub/restart.ts +14 -0
- package/src/hub/settings.ts +3 -0
- package/src/hub/startup-event-seeds.ts +51 -0
- package/src/hub/startup-readiness.ts +65 -0
- package/src/hub/startup-timing.ts +38 -0
- package/src/hub/tasks.ts +29 -9
- package/src/hub/ui.ts +9 -5
- package/src/hub/visual-assets.ts +50 -0
- package/src/hub/visual.ts +257 -0
- package/src/memory/brief.ts +169 -14
- package/src/memory/capture.ts +2 -1
- package/src/memory/recall.ts +46 -5
- package/src/memory/record-provenance.ts +82 -0
- package/src/memory/records.ts +116 -0
- package/src/models/route/adec.ts +190 -0
- package/src/models/route/config.ts +37 -3
- package/src/models/route/decision-evidence.ts +83 -0
- package/src/models/route/decision.ts +143 -0
- package/src/models/route/runtime.ts +13 -6
- package/src/ui/index.html +134 -6
- package/src/ui/plugin.ts +65 -4
- package/templates/AGENTS.block.md +1 -1
- package/templates/config.json +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- package/templates/profiles/writing.json +1 -1
package/src/hub/research.ts
CHANGED
|
@@ -5,13 +5,21 @@ import type { RoleIdentity, StampedEvent } from "./events.ts";
|
|
|
5
5
|
import { hubHome } from "./project.ts";
|
|
6
6
|
import { ADDONS, addonState, type Addon, type AddonName } from "./tooling.ts";
|
|
7
7
|
import { MIN_ATTEMPTS, mulberry32 } from "./bench.ts";
|
|
8
|
+
import { UNKNOWN_MEMORY_FIGURES } from "./research-memory.ts";
|
|
9
|
+
import { addPersonEvent, emptyPersonCounts, isPersonEvent, personCoverage, personFigures, personTask, startsAttribution, type PersonCounts } from "./person-figures.ts";
|
|
10
|
+
import { closedId, journalFigures, mergeBlock, NO_EVIDENCE, reviewBlock, specsBlock, type CancelCause, type CohortKind, type DeliveryBlock, type HandoffsBlock, type MemoryBlock, type MergeBlock, type PersonBlock, type ResearchEvidence, type ReviewBlock, type SpecsBlock, cancelCauseOf } from "./research-record.ts";
|
|
11
|
+
export type { CancelCause, CohortKind, DeliveryBlock, HandoffsBlock, MemoryBlock, MergeBlock, PersonBlock, ResearchEvidence, ReviewBlock, SpecsBlock } from "./research-record.ts";
|
|
8
12
|
|
|
9
13
|
/**
|
|
10
14
|
* #247: opt-in research records of how tasks went, built only from `events.jsonl` (ids, states, counts, tokens), so the
|
|
11
15
|
* live writer and `ahub research backfill` produce the same records. Never a title, detail, plan, note, path, message
|
|
12
16
|
* body or check output: the events carry none of those.
|
|
13
17
|
*/
|
|
14
|
-
|
|
18
|
+
/** v1 rows (approved tasks only, no review, delivery, merge, specs, memory or person block) stay readable; every row written now is v2 (#1277). */
|
|
19
|
+
export const RESEARCH_SCHEMA_V1 = "agent-hub.research/v1";
|
|
20
|
+
export const RESEARCH_SCHEMA = "agent-hub.research/v2";
|
|
21
|
+
export type ResearchSchema = typeof RESEARCH_SCHEMA | typeof RESEARCH_SCHEMA_V1;
|
|
22
|
+
const SCHEMAS: readonly string[] = [RESEARCH_SCHEMA_V1, RESEARCH_SCHEMA];
|
|
15
23
|
export const LABELS = ["ok", "regressed", "reverted", "incomplete", "wrong", "abandoned"] as const;
|
|
16
24
|
export type Label = (typeof LABELS)[number];
|
|
17
25
|
/** A person's later verdict that turns an approved task into a failure. */
|
|
@@ -19,16 +27,22 @@ const FAILED: ReadonlySet<string> = new Set(["regressed", "reverted", "incomplet
|
|
|
19
27
|
|
|
20
28
|
export interface PeerUsage { input: number | null; output: number | null; cacheRead: number | null; total: number | null }
|
|
21
29
|
export interface TaskRecord {
|
|
22
|
-
schema:
|
|
30
|
+
schema: ResearchSchema; kind: "task"; project: string; task: number;
|
|
23
31
|
profile?: string; profileClass?: string;
|
|
24
32
|
release?: number | null; priority?: number | null; plannedStart?: number | null; due?: number | null;
|
|
25
33
|
role?: string; roleIdentity?: { id: string; layer: "shipped" | "project"; hash: string };
|
|
26
34
|
/** Native preamble preparations frozen at each attributed turn's start, never proof of model consumption. */
|
|
27
35
|
sessionRoles?: { peer: string; turn: string; roles: RoleIdentity[] | null }[];
|
|
28
|
-
class: string | null; pii: boolean;
|
|
36
|
+
class: string | null; pii: boolean;
|
|
37
|
+
/** #1277 AC6: every task in a terminal board state has a record. A v1 row is always `approved`. Statistics about approved tasks filter on this. */
|
|
38
|
+
outcome: "approved" | "cancelled";
|
|
29
39
|
/** The proposal time also tells a task from an earlier one with the same id after `ahub reset --all`. */
|
|
30
|
-
createdAt: string | null; startedAt: string | null;
|
|
31
|
-
/**
|
|
40
|
+
createdAt: string | null; startedAt: string | null;
|
|
41
|
+
/** Null on a cancelled record. */
|
|
42
|
+
approvedAt: string | null;
|
|
43
|
+
/** When the task reached its terminal state (v2); a v1 row reads as its `approvedAt`. */
|
|
44
|
+
closedAt?: string;
|
|
45
|
+
/** From the first `in_progress` to the terminal state; null when the start is not in the events. */
|
|
32
46
|
wallMs: number | null;
|
|
33
47
|
/** Sum of the attributed turns' durations, the turns still open at the approval included. */
|
|
34
48
|
activeMs: number;
|
|
@@ -57,9 +71,30 @@ export interface TaskRecord {
|
|
|
57
71
|
peers?: string[];
|
|
58
72
|
/** #258 AC11: task briefs handed over for it, and how many of them carried a hub-memory fail note; absent without briefs. */
|
|
59
73
|
briefs?: { delivered: number; withFail: number };
|
|
74
|
+
/** #1277 (v2). Every block is a count, a duration or a closed value; a value that cannot be derived is null and counted as unknown by the quality report, never 0. */
|
|
75
|
+
/** Only on a cancelled record: an explicit closed cause the board retains, else `unknown`. Never inferred from a decline, an escalation or the cancellation note. */
|
|
76
|
+
cancelCause?: CancelCause;
|
|
77
|
+
/** Intermediate declines and escalations (history events, not terminal), counted on every record next to `reassignments`. */
|
|
78
|
+
declines?: number; escalations?: number;
|
|
79
|
+
review?: ReviewBlock; delivery?: DeliveryBlock; merge?: MergeBlock; specs?: SpecsBlock; memory?: MemoryBlock; person?: PersonBlock;
|
|
80
|
+
/** Persisted idle-ladder entries on the task's board history (the stall table itself is cleared when a task closes); null without the board. */
|
|
81
|
+
stalls?: number | null;
|
|
82
|
+
handoffs?: HandoffsBlock; cohort?: CohortKind;
|
|
60
83
|
}
|
|
84
|
+
/** A record carries merge figures when any of them is known. */
|
|
85
|
+
const mergeKnown = (r: TaskRecord): boolean => !!r.merge && (r.merge.ciRuns !== null || r.merge.ciReruns !== null || r.merge.githubReviews !== null || r.merge.approvedToMergedMs !== null);
|
|
86
|
+
/** True when `next` has merge figures the held record of the same task lacks. */
|
|
87
|
+
export const fillsMerge = (held: TaskRecord, next: TaskRecord): boolean => next.schema === RESEARCH_SCHEMA && !mergeKnown(held) && mergeKnown(next);
|
|
88
|
+
|
|
89
|
+
/** The latest record of each task (a later row of the same task replaces an earlier one), in order of first appearance. */
|
|
90
|
+
export function latestTaskRecords(records: readonly ResearchRecord[]): TaskRecord[] {
|
|
91
|
+
const latest = new Map<string, TaskRecord>();
|
|
92
|
+
for (const r of records) if (r.kind === "task") latest.set(recordKey(r), r);
|
|
93
|
+
return [...latest.values()];
|
|
94
|
+
}
|
|
95
|
+
|
|
61
96
|
/** A person's later verdict on one task record: `createdAt` binds it to that task, not a later one with the same id. `by`: the agent that labelled its own task (#933); absent for the person. */
|
|
62
|
-
export interface LabelRecord { schema:
|
|
97
|
+
export interface LabelRecord { schema: ResearchSchema; kind: "label"; project: string; task: number; createdAt: string; label: Label; at: string; by?: string }
|
|
63
98
|
export type ResearchRecord = TaskRecord | LabelRecord;
|
|
64
99
|
|
|
65
100
|
/** The store name for a project: a hash of its id, never its path. */
|
|
@@ -67,29 +102,43 @@ export const projectKey = (projectId: string): string => createHash("sha256").up
|
|
|
67
102
|
export const researchDir = (home = hubHome()): string => join(home, "research");
|
|
68
103
|
export const researchFile = (projectId: string, home = hubHome()): string => join(researchDir(home), `${projectKey(projectId)}.jsonl`);
|
|
69
104
|
/** One task across every record about it: a task id is reused after `ahub reset --all`, its proposal time is not. */
|
|
70
|
-
export const recordKey = (r: { project: string; task: number; createdAt: string | null; approvedAt?: string }): string => `${r.project}#${r.task}@${r.createdAt ?? r.approvedAt ?? ""}`;
|
|
105
|
+
export const recordKey = (r: { project: string; task: number; createdAt: string | null; approvedAt?: string | null; closedAt?: string }): string => `${r.project}#${r.task}@${r.createdAt ?? r.approvedAt ?? r.closedAt ?? ""}`;
|
|
106
|
+
/** When a record's task closed: v2 says so, a v1 row is approved-only and closed at its approval. */
|
|
107
|
+
export const closedAtOf = (r: TaskRecord): string | null => r.closedAt ?? r.approvedAt;
|
|
71
108
|
|
|
72
|
-
type
|
|
109
|
+
type V2Fields = "closedAt" | "cancelCause" | "declines" | "escalations" | "review" | "delivery" | "merge" | "specs" | "memory" | "person" | "stalls" | "handoffs" | "cohort";
|
|
110
|
+
type Acc = Omit<TaskRecord, "schema" | "kind" | "project" | "approvedAt" | "wallMs" | "firstPass" | "outcome" | "writer" | "models" | "tooling" | "peers" | "briefs" | "person" | V2Fields> & {
|
|
73
111
|
models: Set<string>; approvedAt: string | null; lastState: string | null; tooling: Record<string, Addon[]>; peers: Map<string, number>; briefs: { delivered: number; withFail: number };
|
|
112
|
+
/** #1278: the counts of the four attributed person events; joined with whether the log covers the task at the end. */
|
|
113
|
+
person: PersonCounts;
|
|
114
|
+
/** #1277: what the events alone say; the board and journal evidence is joined at the end. */
|
|
115
|
+
cancelledAt: string | null; declines: number; escalations: number; lastDoneMs: number | null; envelopes: number; dropped: { fyi: number; hop: number };
|
|
116
|
+
briefNotes: number; briefFails: number; cohort: "silent" | "advisory" | null;
|
|
74
117
|
};
|
|
75
118
|
/** Keyed by peer ids, model names and reasons, any of which may be `constructor` or `__proto__`: no prototype to inherit from. */
|
|
76
119
|
const keyed = <T>(): Record<string, T> => Object.create(null) as Record<string, T>;
|
|
77
120
|
const blank = (task: number): Acc => ({ task, class: null, pii: false, createdAt: null, startedAt: null, activeMs: 0, owners: [], reviewer: null,
|
|
78
121
|
reviewRounds: 0, changesRequested: 0, checkPassed: 0, checkFailed: 0, dones: 0, reassignments: 0, reassignedBy: keyed(), stuck: 0, overlaps: 0, conflicts: 0,
|
|
79
122
|
tests: { pass: 0, fail: 0 }, tokens: { total: 0, byPeer: keyed(), byAttribution: keyed() }, usage: keyed(), usageByModel: keyed(), turns: 0, filesChanged: 0, models: new Set(),
|
|
80
|
-
approvedAt: null, lastState: null, tooling: keyed(), peers: new Map(), briefs: { delivered: 0, withFail: 0 }
|
|
123
|
+
approvedAt: null, lastState: null, tooling: keyed(), peers: new Map(), briefs: { delivered: 0, withFail: 0 }, person: emptyPersonCounts(),
|
|
124
|
+
cancelledAt: null, declines: 0, escalations: 0, lastDoneMs: null, envelopes: 0, dropped: { fyi: 0, hop: 0 }, briefNotes: 0, briefFails: 0, cohort: null });
|
|
81
125
|
const add = (current: number | null, value: unknown): number | null => typeof value === "number" && Number.isFinite(value) ? (current ?? 0) + value : current;
|
|
82
126
|
const bump = (counts: Record<string, number>, key: string, n = 1) => { counts[key] = (counts[key] ?? 0) + n; };
|
|
83
127
|
|
|
84
128
|
/**
|
|
85
|
-
* One record per
|
|
129
|
+
* One record per task that reached a terminal board state in `events`: approved, or cancelled (#1277 AC6). Approval is final on the board, so everything attributed to the task
|
|
86
130
|
* counts, also after the approval: the turn that approved it ends later, and its usage arrives after that. The live
|
|
87
|
-
* writer therefore waits for those turns (daemon `recordResearch`); backfill reads to the end of the file.
|
|
131
|
+
* writer therefore waits for those turns (daemon `recordResearch`); backfill reads to the end of the file. `evidence` (the board's history and the delivery journal,
|
|
132
|
+
* read-only and closed fields only, `readEvidence`) fills the review, merge, spec and delivery-state blocks; without it those fields are null, not 0. A task that was approved
|
|
133
|
+
* and later reopened keeps its approved record, as before.
|
|
88
134
|
*/
|
|
89
|
-
export function taskRecords(events: StampedEvent[], projectId: string, writer: TaskRecord["writer"]): TaskRecord[] {
|
|
135
|
+
export function taskRecords(events: StampedEvent[], projectId: string, writer: TaskRecord["writer"], evidence: ResearchEvidence = NO_EVIDENCE): TaskRecord[] {
|
|
90
136
|
const tasks = new Map<number, Acc>(), earlier: Acc[] = [];
|
|
91
137
|
const acc = (id: number) => { let a = tasks.get(id); if (!a) tasks.set(id, a = blank(id)); return a; };
|
|
92
138
|
const seenUsage = new Set<string>();
|
|
139
|
+
let briefReporting = false;
|
|
140
|
+
// #1278: the time of the first hub run that attributes person events. A task that began before it has an unknown person block (see `startsAttribution`).
|
|
141
|
+
let personReportingSince: number | null = null;
|
|
93
142
|
// A fingerprint belongs to one attachment of its peer: from a state event that brings it online to the next offline.
|
|
94
143
|
// Detection settles in the background, so it can arrive after the peer's first work in that attachment.
|
|
95
144
|
const attach = new Map<string, number>(), online = new Set<string>();
|
|
@@ -109,7 +158,7 @@ export function taskRecords(events: StampedEvent[], projectId: string, writer: T
|
|
|
109
158
|
};
|
|
110
159
|
for (const e of events) {
|
|
111
160
|
// A crash writes no offline: a new run is a new attachment, and no fingerprint from before it describes later work.
|
|
112
|
-
if (e.type === "hub_start") { online.clear(); tooling.clear(); continue; }
|
|
161
|
+
if (e.type === "hub_start") { online.clear(); tooling.clear(); if (startsAttribution(e)) personReportingSince ??= Date.parse(e.at); continue; }
|
|
113
162
|
if (e.type === "state") {
|
|
114
163
|
if (e.state === "offline") online.delete(e.peer);
|
|
115
164
|
else if (!online.has(e.peer)) { online.add(e.peer); attach.set(e.peer, (attach.get(e.peer) ?? 0) + 1); }
|
|
@@ -121,6 +170,15 @@ export function taskRecords(events: StampedEvent[], projectId: string, writer: T
|
|
|
121
170
|
for (const a of tasks.values()) if (a.peers.get(e.peer) === now) a.tooling[e.peer] = e.addons;
|
|
122
171
|
continue;
|
|
123
172
|
}
|
|
173
|
+
if (e.type === "envelope") {
|
|
174
|
+
// An envelope names its task as a string; a count of them, and of the ones the bus dropped, is all the record keeps.
|
|
175
|
+
if (typeof e.task === "string" && /^[1-9]\d{0,9}$/.test(e.task)) { const a = acc(Number(e.task)); a.envelopes++; if (e.dropped === "fyi" || e.dropped === "hop") a.dropped[e.dropped]++; }
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (e.type === "cohort") {
|
|
179
|
+
for (const id of e.tasks) if (Number.isSafeInteger(id) && id > 0) { const a = acc(id); if (e.silent) a.cohort = "silent"; else a.cohort ??= "advisory"; }
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
124
182
|
if (e.type === "task") {
|
|
125
183
|
const proposed = e.event === "proposed" || e.event === "backlog pulled";
|
|
126
184
|
// Ids restart after `ahub reset --all` archives the events, but a file that kept both starts the task anew.
|
|
@@ -136,16 +194,25 @@ export function taskRecords(events: StampedEvent[], projectId: string, writer: T
|
|
|
136
194
|
if (e.reviewer) a.reviewer = e.reviewer;
|
|
137
195
|
if (proposed) a.createdAt ??= e.at;
|
|
138
196
|
if (e.state === "in_progress") a.startedAt ??= e.at;
|
|
139
|
-
if (e.event === "done") a.dones++;
|
|
197
|
+
if (e.event === "done") { a.dones++; if (a.approvedAt === null) a.lastDoneMs = Date.parse(e.at); }
|
|
198
|
+
if (e.event === "declined") a.declines++;
|
|
199
|
+
if (e.event === "escalated") a.escalations++;
|
|
140
200
|
if (e.event === "check passed") a.checkPassed++;
|
|
141
201
|
if (e.event === "check failed") a.checkFailed++;
|
|
142
202
|
if (e.state === "in_review" && a.lastState !== "in_review") a.reviewRounds++;
|
|
143
203
|
if (e.state === "changes_requested" && a.lastState !== "changes_requested") a.changesRequested++;
|
|
144
204
|
if (e.state === "approved") a.approvedAt ??= e.at;
|
|
205
|
+
if (e.state === "cancelled" && a.lastState !== "cancelled") a.cancelledAt = e.at;
|
|
145
206
|
a.lastState = e.state;
|
|
146
207
|
involve(a, e.by); involve(a, e.owner, false); involve(a, e.reviewer, false);
|
|
147
208
|
continue;
|
|
148
209
|
}
|
|
210
|
+
if (isPersonEvent(e)) {
|
|
211
|
+
// Before the generic path on purpose: a permission or a question does not make its peer a worker on the task (`peers`, `tooling`).
|
|
212
|
+
const owner = personTask(e);
|
|
213
|
+
if (owner !== undefined) { const a = acc(owner); if (e.pii) a.pii = true; addPersonEvent(a.person, e); }
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
149
216
|
const task = "task" in e && typeof e.task === "number" ? e.task : undefined;
|
|
150
217
|
if (task === undefined) continue;
|
|
151
218
|
const a = acc(task);
|
|
@@ -168,22 +235,45 @@ export function taskRecords(events: StampedEvent[], projectId: string, writer: T
|
|
|
168
235
|
}
|
|
169
236
|
else if (e.type === "route") a.models.add(e.route);
|
|
170
237
|
else if (e.type === "route_outcome") { if (e.next?.tests === "pass") a.tests.pass++; if (e.next?.tests === "fail") a.tests.fail++; }
|
|
171
|
-
else if (e.type === "memory_brief") { a.briefs.delivered++; if (e.fails > 0) a.briefs.withFail++; }
|
|
238
|
+
else if (e.type === "memory_brief") { briefReporting = true; a.briefs.delivered++; if (e.fails > 0) a.briefs.withFail++; a.briefNotes += (Number.isSafeInteger(e.native) ? e.native : 0) + (Number.isSafeInteger(e.claudeMem) ? e.claudeMem : 0); a.briefFails += Number.isSafeInteger(e.fails) ? e.fails : 0; }
|
|
172
239
|
else if (e.type === "stuck") a.stuck++;
|
|
173
240
|
else if (e.type === "overlap") a.overlaps++;
|
|
174
241
|
else if (e.type === "conflict") a.conflicts++;
|
|
175
242
|
}
|
|
176
243
|
const out: TaskRecord[] = [];
|
|
177
244
|
for (const a of [...earlier, ...tasks.values()]) {
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
245
|
+
// Approved wins over a later reopening (the record the live writer stored at the approval); otherwise the task must end cancelled.
|
|
246
|
+
const outcome: "approved" | "cancelled" | undefined = a.approvedAt ? "approved" : a.lastState === "cancelled" && a.cancelledAt ? "cancelled" : undefined;
|
|
247
|
+
if (!outcome) continue;
|
|
248
|
+
const { person, approvedAt, lastState: _state, models, tooling: fingerprints, peers, briefs, cancelledAt, declines, escalations, lastDoneMs, envelopes, dropped, briefNotes, briefFails, cohort, ...rest } = a;
|
|
249
|
+
const closedAt = (outcome === "approved" ? approvedAt : cancelledAt)!;
|
|
250
|
+
const start = a.startedAt ? Date.parse(a.startedAt) : NaN, end = Date.parse(closedAt);
|
|
251
|
+
const task = evidence.tasks.get(a.task), board = evidence.board;
|
|
252
|
+
const began = a.createdAt ? Date.parse(a.createdAt) : a.startedAt ? Date.parse(a.startedAt) : NaN;
|
|
253
|
+
const covered = personCoverage(personReportingSince, began);
|
|
254
|
+
const ownerFamily = closedId(a.owners.at(-1) ? evidence.familyOf?.(a.owners.at(-1)!) : undefined);
|
|
255
|
+
const approvedMs = outcome === "approved" ? Date.parse(approvedAt!) : null;
|
|
256
|
+
const created = a.createdAt ? Date.parse(a.createdAt) : NaN;
|
|
257
|
+
const journal = journalFigures(evidence, a.task, Number.isFinite(created) ? created : null, Number.isFinite(end) ? end : 0);
|
|
258
|
+
const review = reviewBlock(task, board, ownerFamily, a.pii);
|
|
259
|
+
// The events say whether the task went to review at all; a board that cannot say leaves the round count unknown.
|
|
260
|
+
out.push({ schema: RESEARCH_SCHEMA, kind: "task", project: projectKey(projectId), ...rest, outcome, approvedAt: approvedAt ?? null, closedAt,
|
|
182
261
|
wallMs: Number.isFinite(start) && Number.isFinite(end) && end >= start ? end - start : null,
|
|
183
|
-
firstPass: a.changesRequested === 0 && a.checkFailed === 0 && a.reviewRounds <= 1, models: [...models].sort(), writer,
|
|
184
|
-
...(Object.keys(fingerprints).length ? { tooling: fingerprints, peers: [...peers.keys()].sort() } : {}), ...(briefs.delivered ? { briefs } : {})
|
|
262
|
+
firstPass: outcome === "approved" && a.changesRequested === 0 && a.checkFailed === 0 && a.reviewRounds <= 1, models: [...models].sort(), writer,
|
|
263
|
+
...(Object.keys(fingerprints).length ? { tooling: fingerprints, peers: [...peers.keys()].sort() } : {}), ...(briefs.delivered ? { briefs } : {}),
|
|
264
|
+
...(outcome === "cancelled" ? { cancelCause: task?.history.findLast(entry => entry.event === "cancelled")?.cause ?? "unknown" } : {}),
|
|
265
|
+
declines, escalations, review,
|
|
266
|
+
delivery: { count: envelopes, ...journal, dropped: { fyi: dropped.fyi, hop: dropped.hop } },
|
|
267
|
+
merge: mergeBlock(task, lastDoneMs, approvedMs),
|
|
268
|
+
specs: specsBlock(task, a.pii),
|
|
269
|
+
// A task with no brief is a known 0 only when the log shows briefs were being reported at all (memory on); otherwise nothing says whether one was built.
|
|
270
|
+
memory: { briefs: briefs.delivered || (briefReporting ? 0 : null), briefNotes: briefs.delivered ? briefNotes : briefReporting ? 0 : null, fails: briefs.delivered ? briefFails : briefReporting ? 0 : null, ...(evidence.memory?.(a.task) ?? UNKNOWN_MEMORY_FIGURES) },
|
|
271
|
+
person: personFigures(person, covered),
|
|
272
|
+
stalls: task && board ? task.history.filter(entry => entry.sweep).length : null,
|
|
273
|
+
handoffs: task?.handoffs ? { in: task.handoffs.in, out: task.handoffs.out } : { in: null, out: null },
|
|
274
|
+
cohort: cohort ?? "none" });
|
|
185
275
|
}
|
|
186
|
-
return out.sort((x, y) => x
|
|
276
|
+
return out.sort((x, y) => closedAtOf(x)!.localeCompare(closedAtOf(y)!) || x.task - y.task);
|
|
187
277
|
}
|
|
188
278
|
|
|
189
279
|
/** Every record in one store file; a line that does not parse is skipped. */
|
|
@@ -191,9 +281,13 @@ export function readStore(file: string): ResearchRecord[] {
|
|
|
191
281
|
if (!existsSync(file)) return [];
|
|
192
282
|
return readFileSync(file, "utf8").split("\n").flatMap((line) => {
|
|
193
283
|
if (!line.trim()) return [];
|
|
194
|
-
try { const r = JSON.parse(line); return r?.schema
|
|
284
|
+
try { const r = JSON.parse(line); return SCHEMAS.includes(r?.schema) ? [normalize(r)] : []; } catch { return []; }
|
|
195
285
|
});
|
|
196
286
|
}
|
|
287
|
+
/** A v1 task row predates `outcome` and was written only at an approval, so it reads as approved; the file itself is never rewritten. */
|
|
288
|
+
function normalize(r: ResearchRecord): ResearchRecord {
|
|
289
|
+
return r.kind === "task" && r.schema === RESEARCH_SCHEMA_V1 && r.outcome === undefined ? { ...r, outcome: "approved" } : r;
|
|
290
|
+
}
|
|
197
291
|
/** This project's store, or every project's with `all`. */
|
|
198
292
|
export function readStores(projectId: string | undefined, home = hubHome()): ResearchRecord[] {
|
|
199
293
|
if (projectId) return readStore(researchFile(projectId, home));
|
|
@@ -208,8 +302,10 @@ export function appendRecords(projectId: string, records: ResearchRecord[], home
|
|
|
208
302
|
const file = researchFile(projectId, home);
|
|
209
303
|
mkdirSync(researchDir(home), { recursive: true, mode: 0o700 });
|
|
210
304
|
chmodSync(researchDir(home), 0o700); // mkdir's mode applies only when it creates the directory
|
|
211
|
-
|
|
212
|
-
|
|
305
|
+
// A v2 row replaces a held v1 row of the same task (readers keep the latest per task). A held v2 row is final, as a held row always was, with one exception: the live writer
|
|
306
|
+
// records a task at its approval and the guarded merge writes its receipt later, so a later pass may append the same task with the merge figures it could not know then.
|
|
307
|
+
const held = new Map(readStore(file).flatMap((r) => r.kind === "task" && r.schema === RESEARCH_SCHEMA ? [[recordKey(r), r] as const] : []));
|
|
308
|
+
const added = records.filter((r) => r.kind === "label" || r.schema !== RESEARCH_SCHEMA || !held.has(recordKey(r)) || fillsMerge(held.get(recordKey(r))!, r));
|
|
213
309
|
if (!added.length) return 0;
|
|
214
310
|
appendFileSync(file, added.map((r) => JSON.stringify(r)).join("\n") + "\n", { mode: 0o600 });
|
|
215
311
|
chmodSync(file, 0o600);
|
|
@@ -224,7 +320,9 @@ export function appendRecords(projectId: string, records: ResearchRecord[], home
|
|
|
224
320
|
*/
|
|
225
321
|
export function labelTarget(records: ResearchRecord[], events: StampedEvent[], task: number, created?: string): TaskRecord {
|
|
226
322
|
// A record without a proposal time (its proposal was not in the events) cannot be told apart, so it takes no label.
|
|
227
|
-
|
|
323
|
+
// A label is a verdict on an approved task: a cancelled record takes none.
|
|
324
|
+
// One candidate per task: a later revision of a record (the merge figures filled in, or a v2 row over a v1 one) is the same task, and listing it twice would refuse an unambiguous historical label.
|
|
325
|
+
const mine = latestTaskRecords(records).filter((r) => r.outcome === "approved" && r.task === task && r.createdAt !== null);
|
|
228
326
|
if (created !== undefined) {
|
|
229
327
|
return mine.find((r) => r.createdAt === created) ?? fail(`no research record for task #${task} proposed at ${created}${mine.length ? ` (it has: ${mine.map((r) => r.createdAt).join(", ")})` : ""}`);
|
|
230
328
|
}
|
|
@@ -311,7 +409,8 @@ const labelValues = (labels: Map<string, LabelRecord>): Map<string, Label> => ne
|
|
|
311
409
|
/** Each approved task once, its last record, and every task's effective label: what both reports count. */
|
|
312
410
|
function latestRecords(records: ResearchRecord[], since: number): { all: TaskRecord[]; labels: Map<string, Label> } {
|
|
313
411
|
const latest = new Map<string, TaskRecord>();
|
|
314
|
-
|
|
412
|
+
// Statistics about approved tasks: a cancelled record (#1277) is another outcome and never counts here.
|
|
413
|
+
for (const r of records) if (r.kind === "task" && r.outcome === "approved" && Date.parse(r.approvedAt!) >= since) latest.set(recordKey(r), r);
|
|
315
414
|
return { all: [...latest.values()], labels: labelValues(effectiveLabels(records)) };
|
|
316
415
|
}
|
|
317
416
|
|
package/src/hub/restart.ts
CHANGED
|
@@ -46,6 +46,7 @@ export interface RestartSnapshot {
|
|
|
46
46
|
manualPaused: PeerId[];
|
|
47
47
|
boardDigest: string;
|
|
48
48
|
budgetDigest: string;
|
|
49
|
+
modelOverrides?: { count: number; digest: string };
|
|
49
50
|
/** Whole captured binding set; portable identities are covered alongside queue/board integrity. */
|
|
50
51
|
externalBindingsDigest?: string;
|
|
51
52
|
/** Research state remains part of controlled-recovery integrity when present. */
|
|
@@ -56,6 +57,8 @@ export interface RestartSnapshot {
|
|
|
56
57
|
researchRunners?: { count: number; digest: string };
|
|
57
58
|
researchEvidence?: { links: number; revisions: number; digest: string };
|
|
58
59
|
reviewComments?: { comments: number; packages: number; digest: string };
|
|
60
|
+
/** Stored visual payloads and image metadata, never external image bytes. */
|
|
61
|
+
visuals?: { count: number; digest: string };
|
|
59
62
|
};
|
|
60
63
|
}
|
|
61
64
|
|
|
@@ -106,6 +109,11 @@ export function readRestartSnapshot(stateDir: string, expected: RestartReadExpec
|
|
|
106
109
|
if (s.peers.some(peer => Object.hasOwn(peer, "controlProtocol") && (typeof peer.controlProtocol !== "number" || !Number.isSafeInteger(peer.controlProtocol) || peer.controlProtocol < 1))) return undefined;
|
|
107
110
|
if (s.integrity !== undefined && (!s.integrity || typeof s.integrity !== "object" || typeof s.integrity.boardDigest !== "string" || typeof s.integrity.budgetDigest !== "string" || !Array.isArray(s.integrity.manualPaused) || !s.integrity.manualPaused.every((id) => typeof id === "string") || !s.integrity.queues || typeof s.integrity.queues !== "object" || !Object.values(s.integrity.queues).every((ids) => Array.isArray(ids) && ids.every((id) => typeof id === "string")))) return undefined;
|
|
108
111
|
if (s.integrity && Object.hasOwn(s.integrity, "externalBindingsDigest") && (typeof s.integrity.externalBindingsDigest !== "string" || !/^[a-f0-9]{64}$/.test(s.integrity.externalBindingsDigest))) return undefined;
|
|
112
|
+
if (s.integrity && Object.hasOwn(s.integrity, "modelOverrides")) {
|
|
113
|
+
const value = s.integrity.modelOverrides;
|
|
114
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).sort().join(",") !== "count,digest"
|
|
115
|
+
|| !Number.isSafeInteger(value.count) || value.count < 0 || typeof value.digest !== "string" || !/^[a-f0-9]{64}$/.test(value.digest)) return undefined;
|
|
116
|
+
}
|
|
109
117
|
if (s.integrity && Object.hasOwn(s.integrity, "research")) {
|
|
110
118
|
const research = s.integrity.research;
|
|
111
119
|
if (!research || typeof research !== "object" || Array.isArray(research)
|
|
@@ -144,6 +152,12 @@ export function readRestartSnapshot(stateDir: string, expected: RestartReadExpec
|
|
|
144
152
|
|| ![value.comments, value.packages].every(count => Number.isSafeInteger(count) && count >= 0)
|
|
145
153
|
|| typeof value.digest !== "string" || !/^[a-f0-9]{64}$/.test(value.digest)) return undefined;
|
|
146
154
|
}
|
|
155
|
+
if (s.integrity && Object.hasOwn(s.integrity, "visuals")) {
|
|
156
|
+
const value = s.integrity.visuals;
|
|
157
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some(key => !["count", "digest"].includes(key))
|
|
158
|
+
|| !Number.isSafeInteger(value.count) || value.count < 0
|
|
159
|
+
|| typeof value.digest !== "string" || !/^[a-f0-9]{64}$/.test(value.digest)) return undefined;
|
|
160
|
+
}
|
|
147
161
|
if (bus.journal !== undefined) { try { validateJournalSnapshot(bus.journal); } catch { return undefined; } }
|
|
148
162
|
return s as RestartSnapshot;
|
|
149
163
|
}
|
package/src/hub/settings.ts
CHANGED
|
@@ -80,7 +80,10 @@ export const SETTINGS: readonly SettingDef[] = [
|
|
|
80
80
|
...ADAPTERS.flatMap(adapter => adapter.configuration?.settings ?? []),
|
|
81
81
|
{ key: "routing.stay_switch", group: "Routing", label: "Stay or switch (hub/auto and stage routes)", type: "enum", values: ["off", "shadow", "enforce"], store: "routing", applies: "live", risk: "raises", path: ["stay_switch"] },
|
|
82
82
|
...CLASSES.flatMap(perClass),
|
|
83
|
+
{ key: "visuals.retention_days", group: "Switches", label: "Unbound visual retention (days)", type: "integer", min: 1, max: 3650, store: "config", applies: "live", risk: "raises", floor: 1, path: ["visuals", "retention_days"] },
|
|
83
84
|
{ key: "research.enabled", group: "Switches", label: "Research records", type: "boolean", store: "config", applies: "live", risk: "safe", path: ["research", "enabled"] },
|
|
85
|
+
// #1292: the quota share at which a peer checkpoints and pauses (resume is below gate - 0.1); default 0.99 in DEFAULT_BUDGET. Raising it is guarded.
|
|
86
|
+
{ key: "budget.gate", group: "Routing", label: "Budget gate: pause a peer when a fresh quota window reaches this share (0..1)", type: "number", min: 0, max: 1, store: "config", applies: "next use", risk: "raises", path: ["budget", "gate"] },
|
|
84
87
|
{ key: "specs.enabled", group: "Switches", label: "Design specs and freshness", type: "boolean", store: "config", applies: "live", risk: "safe", path: ["specs", "enabled"] },
|
|
85
88
|
{ key: "specs.review", group: "Switches", label: "Covering spec review", type: "enum", values: ["off", "report", "require"], store: "config", applies: "live", risk: "safe", path: ["specs", "review"] },
|
|
86
89
|
{ key: "approvals.notify", group: "Switches", label: "Desktop notice for a waiting approval", type: "boolean", store: "config", applies: "live", risk: "safe", path: ["approvals", "notify"] },
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { closeSync, existsSync, openSync, readSync } from "node:fs";
|
|
2
|
+
import { StringDecoder } from "node:string_decoder";
|
|
3
|
+
import { parseEventLine, type StampedEvent } from "./events.ts";
|
|
4
|
+
|
|
5
|
+
const STARTUP_EVENT_BUFFER_BYTES = 64 * 1024;
|
|
6
|
+
export interface StartupEventSeeds {
|
|
7
|
+
events: StampedEvent[];
|
|
8
|
+
nativeStopsSeen: Set<string>;
|
|
9
|
+
claudeNativeUsageSeen: Set<string>;
|
|
10
|
+
readPasses: number;
|
|
11
|
+
readBytes: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** One streaming pass, retaining every accepted row and every historical dedupe key. */
|
|
15
|
+
export function readStartupEventSeeds(file: string, options: { bufferBytes?: number } = {}): StartupEventSeeds {
|
|
16
|
+
const seeds: StartupEventSeeds = { events: [], nativeStopsSeen: new Set(), claudeNativeUsageSeen: new Set(), readPasses: 0, readBytes: 0 };
|
|
17
|
+
if (!existsSync(file)) return seeds;
|
|
18
|
+
// Host-only chunk-boundary test seam, never a line, history or total-byte ceiling.
|
|
19
|
+
const bufferBytes = options.bufferBytes ?? STARTUP_EVENT_BUFFER_BYTES;
|
|
20
|
+
if (!Number.isSafeInteger(bufferBytes) || bufferBytes < 1) throw new Error("startup event buffer must be a positive integer; use the default reader buffer");
|
|
21
|
+
const decoder = new StringDecoder("utf8"), buffer = Buffer.alloc(bufferBytes), fd = openSync(file, "r");
|
|
22
|
+
let parts: string[] = [];
|
|
23
|
+
const accept = (line: string) => {
|
|
24
|
+
const event = parseEventLine(line);
|
|
25
|
+
if (event === undefined) return;
|
|
26
|
+
seeds.events.push(event);
|
|
27
|
+
// Keep the same predicates as the former three readEvents consumers, including legacy malformed IDs.
|
|
28
|
+
if (event.type === "native_turn_end" && event.id) seeds.nativeStopsSeen.add(event.id);
|
|
29
|
+
if (event.type === "usage" && event.source === "claude_transcript") seeds.claudeNativeUsageSeen.add((event as { id: string }).id);
|
|
30
|
+
};
|
|
31
|
+
const consume = (text: string) => {
|
|
32
|
+
let start = 0;
|
|
33
|
+
for (let end = text.indexOf("\n", start); end >= 0; end = text.indexOf("\n", start)) {
|
|
34
|
+
parts.push(text.slice(start, end)); accept(parts.join("")); parts = []; start = end + 1;
|
|
35
|
+
}
|
|
36
|
+
if (start < text.length) parts.push(text.slice(start));
|
|
37
|
+
};
|
|
38
|
+
try {
|
|
39
|
+
seeds.readPasses++;
|
|
40
|
+
for (;;) {
|
|
41
|
+
const bytes = readSync(fd, buffer, 0, buffer.length, null);
|
|
42
|
+
if (!bytes) break;
|
|
43
|
+
seeds.readBytes += bytes;
|
|
44
|
+
consume(decoder.write(buffer.subarray(0, bytes)));
|
|
45
|
+
}
|
|
46
|
+
consume(decoder.end());
|
|
47
|
+
// readEvents also accepts a complete final JSON record without a newline.
|
|
48
|
+
if (parts.length) accept(parts.join(""));
|
|
49
|
+
return seeds;
|
|
50
|
+
} finally { closeSync(fd); }
|
|
51
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const STARTUP_NOTICE_MS = 12_000;
|
|
2
|
+
const STARTUP_OUTER_MS = 120_000;
|
|
3
|
+
const STARTUP_POLL_MS = 100;
|
|
4
|
+
const STARTUP_INSPECTION_MS = 3000;
|
|
5
|
+
|
|
6
|
+
export type StartupOwner = { state: "live"; pid: number } | { state: "gone" } | { state: "unknown" } | undefined;
|
|
7
|
+
export interface StartupReadinessPorts<T> {
|
|
8
|
+
now(): number;
|
|
9
|
+
sleep(ms: number): Promise<void>;
|
|
10
|
+
inspect(timeoutMs: number): Promise<{ state: string; status?: T; error?: string }>;
|
|
11
|
+
current(): void;
|
|
12
|
+
/** A live result requires the bound spawned child and its full process identity, never PID-only evidence. */
|
|
13
|
+
owner(): StartupOwner;
|
|
14
|
+
confirm(status: T): void;
|
|
15
|
+
starting?(pid: number): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Keep the existing status promise pending; only readiness confirms the retained runtime selection. */
|
|
19
|
+
export async function waitForStartupReadiness<T>(ports: StartupReadinessPorts<T>): Promise<T> {
|
|
20
|
+
const started = ports.now(), deadline = started + STARTUP_OUTER_MS;
|
|
21
|
+
let reported = false, owner: StartupOwner;
|
|
22
|
+
const expired = (): never => {
|
|
23
|
+
throw new Error(owner?.state === "live"
|
|
24
|
+
? `hub readiness remains unconfirmed after ${STARTUP_OUTER_MS / 1000} s; its daemon was last observed running as pid ${owner.pid}; run ahub status and inspect hub.log before retrying`
|
|
25
|
+
: `hub readiness remains unconfirmed after ${STARTUP_OUTER_MS / 1000} s; run ahub status and inspect hub.log before retrying`);
|
|
26
|
+
};
|
|
27
|
+
for (;;) {
|
|
28
|
+
ports.current();
|
|
29
|
+
const remaining = deadline - ports.now();
|
|
30
|
+
if (remaining <= 0) return expired();
|
|
31
|
+
// inspectProject has a connect and a status timeout. A zero timeout would disable the request timer.
|
|
32
|
+
const timeout = Math.min(STARTUP_INSPECTION_MS, Math.floor(remaining / 2));
|
|
33
|
+
if (timeout < 1) {
|
|
34
|
+
await ports.sleep(remaining);
|
|
35
|
+
ports.current();
|
|
36
|
+
if (ports.now() >= deadline) return expired();
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const observed = await ports.inspect(timeout);
|
|
40
|
+
ports.current();
|
|
41
|
+
if (ports.now() >= deadline) return expired();
|
|
42
|
+
if (observed.state === "running") {
|
|
43
|
+
if (observed.status === undefined) throw new Error("hub readiness reply has no status; run ahub status before retrying");
|
|
44
|
+
ports.current();
|
|
45
|
+
if (ports.now() >= deadline) return expired();
|
|
46
|
+
ports.confirm(observed.status);
|
|
47
|
+
return observed.status;
|
|
48
|
+
}
|
|
49
|
+
if (observed.state === "incompatible" || observed.state === "missing") throw new Error(observed.error ?? observed.state);
|
|
50
|
+
if (observed.state === "stopping") throw new Error(observed.error ?? "hub shutdown is pending; wait until it stops before retrying");
|
|
51
|
+
owner = ports.owner();
|
|
52
|
+
ports.current();
|
|
53
|
+
if (ports.now() >= deadline) return expired();
|
|
54
|
+
if (owner?.state === "gone") throw new Error("hub daemon exited before readiness was confirmed; inspect hub.log and run ahub status before retrying");
|
|
55
|
+
if (!reported && ports.now() - started >= STARTUP_NOTICE_MS && owner?.state === "live") {
|
|
56
|
+
reported = true;
|
|
57
|
+
try { ports.starting?.(owner.pid); } catch { /* Advisory progress never changes startup or confirmation. */ }
|
|
58
|
+
}
|
|
59
|
+
const sleep = Math.min(STARTUP_POLL_MS, deadline - ports.now());
|
|
60
|
+
if (sleep <= 0) return expired();
|
|
61
|
+
await ports.sleep(sleep);
|
|
62
|
+
ports.current();
|
|
63
|
+
if (ports.now() >= deadline) return expired();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Closed labels only: startup diagnostics must never carry paths, task text or errors. */
|
|
2
|
+
export const STARTUP_PHASES = [
|
|
3
|
+
"schema", "source", "configuration", "control", "recovery", "journal", "bus", "memory", "model_runtime", "board",
|
|
4
|
+
"task_runtime", "native_stop_seed", "budget", "usage_seed", "usage_rollup", "token_runtime", "token_seed",
|
|
5
|
+
"services", "transcript_usage_seed", "ready", "post_ready",
|
|
6
|
+
] as const;
|
|
7
|
+
export type StartupPhase = typeof STARTUP_PHASES[number];
|
|
8
|
+
|
|
9
|
+
/** Exclusive boundary durations; no timers, I/O, wall clock or startup policy changes. */
|
|
10
|
+
export class StartupTimings {
|
|
11
|
+
private readonly started: number;
|
|
12
|
+
private last = 0;
|
|
13
|
+
private readonly durations = new Map<StartupPhase, number>();
|
|
14
|
+
private line: string | undefined;
|
|
15
|
+
|
|
16
|
+
constructor(private readonly clock: () => number = () => performance.now()) {
|
|
17
|
+
this.started = this.sample();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private sample(): number {
|
|
21
|
+
try {
|
|
22
|
+
const value = this.clock();
|
|
23
|
+
if (Number.isFinite(value)) this.last = Math.max(this.last, Math.floor(value));
|
|
24
|
+
} catch { /* An unavailable diagnostic clock must never fail startup. */ }
|
|
25
|
+
return this.last;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
mark(phase: StartupPhase): void {
|
|
29
|
+
if (this.line !== undefined || !STARTUP_PHASES.includes(phase)) return;
|
|
30
|
+
const before = this.last, now = this.sample();
|
|
31
|
+
this.durations.set(phase, (this.durations.get(phase) ?? 0) + now - before);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
finish(): string {
|
|
35
|
+
// The final caller mark includes all synchronous work through daemon return.
|
|
36
|
+
return this.line ??= `startup timings ms: ${STARTUP_PHASES.map(phase => `${phase}=${this.durations.get(phase) ?? 0}`).join(" ")} total=${this.last - this.started}`;
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/hub/tasks.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { approvalBinding, approvalText } from "./task-approval.ts";
|
|
|
11
11
|
import type { InvocationRow } from "./invocations.ts";
|
|
12
12
|
import type { ContextAdmission } from "./context-window.ts";
|
|
13
13
|
import { queueBackpressure, queueBackpressurePolicy, type QueueBackpressureCode, type QueueBackpressurePolicy, type QueueBackpressureStatus, type QueuePressure } from "./queue-backpressure.ts";
|
|
14
|
+
import { generatedRecordAnchors, type GeneratedNoteProof } from "../memory/record-provenance.ts";
|
|
14
15
|
import { canonicalMemorySymbolNode, normalizeMemorySymbolInput, MEMORY_SYMBOL_LIMIT, type MemorySymbolNodes } from "./memory-symbols.ts";
|
|
15
16
|
import { createHash, randomBytes } from "node:crypto";
|
|
16
17
|
import { types } from "node:util";
|
|
@@ -104,6 +105,9 @@ function requireLocalTaskPeer(peer: unknown): void {
|
|
|
104
105
|
|
|
105
106
|
export type TaskAssignmentRole = "owner" | "reviewer" | "planner";
|
|
106
107
|
|
|
108
|
+
/** What `hub_remember` carries (#802: one shape for `remember` and `rememberOutcome`). */
|
|
109
|
+
export interface RememberInput { text?: string; title?: string; kind?: string; task?: unknown; horizon?: unknown; scope?: unknown; approach?: unknown; symptom?: unknown; cause?: unknown; evidence?: unknown; retry_when?: unknown }
|
|
110
|
+
|
|
107
111
|
interface PreparedSpecInspection {
|
|
108
112
|
report?: SpecTaskReport;
|
|
109
113
|
policyKey: string;
|
|
@@ -204,7 +208,7 @@ export interface TasksDeps {
|
|
|
204
208
|
/** Captured native profile fence at our memory operation boundaries; no board mutation. */
|
|
205
209
|
memoryCheck?: () => void;
|
|
206
210
|
/** #258: the hub's own store, the project name it files entries under (the git chain) and the repo `#N` issues belong to. */
|
|
207
|
-
store?: { store: MemoryStore; project: string; repo: string; deny?: string[]; onNote?: (id: string) => void };
|
|
211
|
+
store?: { store: MemoryStore; project: string; repo: string; deny?: string[]; onNote?: (id: string) => void; onGeneratedNote?: (entry: GeneratedNoteProof) => void };
|
|
208
212
|
/** a line for the human: console tail and hub.log */
|
|
209
213
|
notify: (line: string) => void;
|
|
210
214
|
/** The completion check configured for a class, if any (local config only; issue #7). */
|
|
@@ -8019,7 +8023,13 @@ export class Tasks {
|
|
|
8019
8023
|
return `candidate:${id}; a person reviews it with amem review; memory was not edited`;
|
|
8020
8024
|
}
|
|
8021
8025
|
|
|
8022
|
-
async remember(by: PeerId, input:
|
|
8026
|
+
async remember(by: PeerId, input: RememberInput): Promise<string> { return (await this.rememberOutcome(by, input)).message; }
|
|
8027
|
+
|
|
8028
|
+
/**
|
|
8029
|
+
* #802: what `remember` answered and whether it stored anything, so a caller never decides that from the wording. `saved` is a note in shared memory, `candidate` one held for a person's
|
|
8030
|
+
* review (stored, not yet evidence), `not_saved` a note nothing took. A refusal still throws and never reaches here.
|
|
8031
|
+
*/
|
|
8032
|
+
async rememberOutcome(by: PeerId, input: RememberInput): Promise<{ message: string; outcome: "saved" | "candidate" | "not_saved" }> {
|
|
8023
8033
|
const requestCheck = this.requestCheck(by);
|
|
8024
8034
|
const text = composeRememberText(input);
|
|
8025
8035
|
const task = input.task !== undefined ? this.d.board.get(Number(input.task)) : undefined;
|
|
@@ -8034,7 +8044,7 @@ export class Tasks {
|
|
|
8034
8044
|
if (this.isPii({ signals: detectSignals({ title, detail: text, refs: {} }, this.d.routing(), this.d.cwd) })) throw new Error("this note matches a PII pattern and is not saved: shared memory excludes private data");
|
|
8035
8045
|
if (input.horizon !== undefined && input.horizon !== "short" && input.horizon !== "long") throw new Error('horizon is "short" or "long"');
|
|
8036
8046
|
if (input.scope !== undefined && input.scope !== "project" && input.scope !== "shared") throw new Error('scope is "project" or "shared"');
|
|
8037
|
-
if (!this.d.memory && !this.d.store) return "memory is disabled; nothing saved";
|
|
8047
|
+
if (!this.d.memory && !this.d.store) return { message: "memory is disabled; nothing saved", outcome: "not_saved" };
|
|
8038
8048
|
// Long-term notes can leave the machine (#258); local storage does not relax secret exclusion.
|
|
8039
8049
|
if (looksSecret(`${title}\n${text}`)) throw new Error("this note looks like it holds a secret (a key, token or password) and is not saved");
|
|
8040
8050
|
if (this.aboutPrivatePath(title, text, task)) throw new Error("this note is about a path the project keeps private (pii.paths) and is not saved");
|
|
@@ -8045,15 +8055,15 @@ export class Tasks {
|
|
|
8045
8055
|
if (native && "refused" in native) throw new PreEffectToolRefusal(`${native.refused}; save it with horizon "short" or correct the machine/repository privacy policy, then retry`);
|
|
8046
8056
|
if (task && this.d.memory) this.markMemoryReference(task, by);
|
|
8047
8057
|
const res = !native && this.d.memory ? await this.d.memory.save({ text, ...(title ? { title } : {}), project: this.d.project, metadata: { peer: by, kind, ...(task ? { task: task.id } : {}) } }) : undefined;
|
|
8048
|
-
if (!res && !native) return "memory store unavailable; nothing saved";
|
|
8058
|
+
if (!res && !native) return { message: "memory store unavailable; nothing saved", outcome: "not_saved" };
|
|
8049
8059
|
if (!native && res && typeof res === "object" && "status" in res && res.status === "candidate") {
|
|
8050
|
-
return "saved as a pending memory candidate; a person must review it before it becomes shared evidence";
|
|
8060
|
+
return { message: "saved as a pending memory candidate; a person must review it before it becomes shared evidence", outcome: "candidate" };
|
|
8051
8061
|
}
|
|
8052
8062
|
const line = noteLine(by, kind, title ? `${title}: ${text}` : text);
|
|
8053
8063
|
this.d.notify(line);
|
|
8054
8064
|
// Display identity only: a queued recall may already contain this saved entry. Stored/captured text stays unchanged.
|
|
8055
8065
|
this.d.share?.(by, native ? noteLine(by, kind, `mem:${native.id} ${title ? `${title}: ` : ""}${text}`) : line);
|
|
8056
|
-
return `saved to shared memory${native ? ` (hub memory ${native.id.slice(0, 8)}, ${native.tier}-term)` : ""}; the other agents get it with their next message
|
|
8066
|
+
return { message: `saved to shared memory${native ? ` (hub memory ${native.id.slice(0, 8)}, ${native.tier}-term)` : ""}; the other agents get it with their next message`, outcome: "saved" };
|
|
8057
8067
|
}
|
|
8058
8068
|
|
|
8059
8069
|
/** The task's key in the hub's store: unique across `ahub reset --all`, which restarts the ids. */
|
|
@@ -8097,7 +8107,7 @@ export class Tasks {
|
|
|
8097
8107
|
* long, decisions, contracts and fails are long-term. Fail-open: a store that cannot be written costs one notice.
|
|
8098
8108
|
* The caller has screened the text for PII; never a PII task's.
|
|
8099
8109
|
*/
|
|
8100
|
-
private saveNative(by: PeerId, kind: Kind, text: string, title: string, task: Task | undefined, horizon?: Tier, scope: Scope = "project", symbols?: MemorySymbolNodes): { id: string; tier: Tier } | { refused: string } | undefined {
|
|
8110
|
+
private saveNative(by: PeerId, kind: Kind, text: string, title: string, task: Task | undefined, horizon?: Tier, scope: Scope = "project", symbols?: MemorySymbolNodes): { id: string; tier: Tier; about: string[] } | { refused: string } | undefined {
|
|
8101
8111
|
const st = this.d.store;
|
|
8102
8112
|
if (!st || (task && this.isPii(task))) return undefined;
|
|
8103
8113
|
let longTerm = horizon === "long" || (horizon !== "short" && !!task && task.state === "approved" && PROMOTED_KINDS.includes(kind));
|
|
@@ -8125,8 +8135,8 @@ export class Tasks {
|
|
|
8125
8135
|
}
|
|
8126
8136
|
if (task) this.markMemoryReference(task, by);
|
|
8127
8137
|
const id = st.store.note(row, horizon === "short");
|
|
8128
|
-
st.onNote?.(id);
|
|
8129
|
-
return { id, tier };
|
|
8138
|
+
try { st.onNote?.(id); } catch { /* The native note is committed; an observer cannot make it fail. */ }
|
|
8139
|
+
return { id, tier, about: [...about] };
|
|
8130
8140
|
} catch (e) {
|
|
8131
8141
|
if (longTerm) return { refused: "machine privacy policy unavailable" };
|
|
8132
8142
|
this.releaseNotice(`a ${kind} note was not saved to hub memory (${(e as Error).message})`);
|
|
@@ -8202,6 +8212,16 @@ export class Tasks {
|
|
|
8202
8212
|
if (this.aboutPrivatePath(title, text, task)) return this.releaseNotice(`task #${task.id}: a ${kind} note was not saved to shared memory (it is about a private path)`);
|
|
8203
8213
|
const native = this.saveNative(by, kind, text, title, task, undefined, "project", symbols);
|
|
8204
8214
|
if (native && "refused" in native) return this.releaseNotice(`task #${task.id}: a ${kind} note was not saved to shared memory (${native.refused})`);
|
|
8215
|
+
if (native && "id" in native && this.d.store?.onGeneratedNote) {
|
|
8216
|
+
try {
|
|
8217
|
+
// Existing repository metadata is warmed by normal task admission/completion, never a new provenance probe.
|
|
8218
|
+
// At the extraction ceiling completeness is unknown. Keep the old bytes rather than a partial proof.
|
|
8219
|
+
if (this.d.privateTurn?.(by) === false && this.memoryCodeEligible(task) && native.about.length < 64 && !symbols?.truncated && !symbols?.unknown) {
|
|
8220
|
+
const anchors = generatedRecordAnchors(native.about);
|
|
8221
|
+
if (anchors && anchors.every(anchor => this.nameable(anchor))) this.d.store.onGeneratedNote({ id: native.id, anchors });
|
|
8222
|
+
}
|
|
8223
|
+
} catch { /* Callback/privacy uncertainty cannot affect a saved note or the preceding board write. */ }
|
|
8224
|
+
}
|
|
8205
8225
|
if (!native && this.d.memory) {
|
|
8206
8226
|
try { this.markMemoryReference(task, by); } catch { return this.releaseNotice(`task #${task.id}: memory reference could not be recorded; note was not saved`); }
|
|
8207
8227
|
void this.d.memory.save({ text, title, project: this.d.project, metadata: { peer: by, task: task.id, kind } });
|