@staix/agent-hub 0.12.44 → 0.13.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 +40 -0
- package/README.md +83 -116
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/docs/adding-an-ai-tool.md +102 -14
- package/docs/agent-notes/README.md +15 -0
- package/docs/agent-notes/adapters.md +63 -46
- package/docs/agent-notes/budget.md +7 -2
- package/docs/agent-notes/bus.md +6 -4
- package/docs/agent-notes/daemon.md +15 -8
- package/docs/agent-notes/local-worker.md +6 -4
- package/docs/agent-notes/tasks.md +18 -5
- package/docs/agent-notes/tests.md +11 -9
- package/docs/dogfood.md +92 -0
- package/docs/events.md +14 -1
- package/docs/operations.md +349 -26
- package/docs/profiles.md +2 -2
- package/docs/quickstart.md +12 -2
- package/docs/sdlc.md +61 -13
- package/docs/security.md +24 -12
- package/docs/smoke.md +9 -4
- package/docs/specs/2026-09-19-agent-hub-design.md +873 -13
- package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
- package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
- package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
- package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
- package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
- package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
- package/docs/verification/2026-10-22-0.12.45.md +60 -0
- package/docs/verification/2026-10-22-code-facts.md +78 -0
- package/docs/verification/2026-10-22-codex-reviewer-isolation.md +47 -0
- package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
- package/docs/verification/2026-10-23-0.13.0.md +89 -0
- package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-board-768-light.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-board-390-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
- package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
- package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
- package/docs/verification/dogfood/2026-10-22.md +3109 -0
- package/docs/verified.json +19 -16
- package/package.json +5 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +10120 -9623
- package/src/adapters/adapter-api.ts +2 -0
- package/src/adapters/agy/definition.ts +3 -1
- package/src/adapters/agy/entry.ts +5 -4
- package/src/adapters/claude-channel.ts +32 -12
- package/src/adapters/codex-appserver.ts +19 -2
- package/src/adapters/codex-review-isolation.ts +12 -5
- package/src/adapters/compat.ts +140 -0
- package/src/adapters/conformance/acp.ts +114 -0
- package/src/adapters/conformance/codex.ts +191 -0
- package/src/adapters/conformance/enrollment.ts +43 -0
- package/src/adapters/conformance/factories.ts +17 -0
- package/src/adapters/conformance/fake-paths.ts +12 -0
- package/src/adapters/conformance/fakes/acp-server.ts +267 -0
- package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
- package/src/adapters/conformance/fakes/app-server.ts +119 -0
- package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
- package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
- package/src/adapters/conformance/fakes/model-server.ts +39 -0
- package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
- package/src/adapters/conformance/index.ts +11 -0
- package/src/adapters/conformance/kit.ts +50 -0
- package/src/adapters/conformance/local-pi.ts +100 -0
- package/src/adapters/conformance/owned-acp.ts +44 -0
- package/src/adapters/definitions/builtin-metadata.ts +25 -7
- package/src/adapters/definitions/native-cli-doctor.ts +12 -5
- package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
- package/src/adapters/definitions/native-cli-entry-kimi.ts +7 -1
- package/src/adapters/definitions/native-cli-entry.ts +7 -19
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
- package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
- package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
- package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
- package/src/adapters/definitions/native-cli-preview.ts +2 -11
- package/src/adapters/definitions/qwen-native.ts +7 -3
- package/src/adapters/definitions/qwen.ts +5 -1
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-module-scan.ts +126 -0
- package/src/adapters/external-modules.ts +240 -0
- package/src/adapters/jcode.ts +72 -31
- package/src/adapters/kimi-review.ts +6 -3
- package/src/adapters/pi.ts +2 -0
- package/src/adapters/public.ts +7 -0
- package/src/adapters/registry-types.ts +13 -1
- package/src/adapters/registry.ts +52 -15
- package/src/cli/agents.ts +40 -0
- package/src/cli/console-review.ts +54 -0
- package/src/cli/console-state.ts +124 -29
- package/src/cli/console-symbols.ts +74 -0
- package/src/cli/console-usage.ts +89 -0
- package/src/cli/console.ts +26 -1
- package/src/cli/help.ts +36 -21
- package/src/cli/identity.ts +30 -7
- package/src/cli/init.ts +109 -11
- package/src/cli/main.ts +145 -16
- package/src/cli/output.ts +44 -1
- package/src/cli/preview.ts +1 -1
- package/src/cli/sdlc-release-guard.ts +82 -0
- package/src/cli/upgrade-labels.ts +1 -1
- package/src/cli/upgrade-presentation.ts +1 -1
- package/src/cli/upgrade-restore-state.ts +3 -2
- package/src/cli/upgrade-runtime.ts +4 -1
- package/src/hub/action-class.ts +362 -0
- package/src/hub/action-policy.ts +106 -0
- package/src/hub/board.ts +72 -11
- package/src/hub/budget.ts +71 -2
- package/src/hub/bus.ts +43 -3
- package/src/hub/checks.ts +80 -31
- package/src/hub/code-facts.ts +223 -0
- package/src/hub/config-apply.ts +28 -11
- package/src/hub/config-reload.ts +8 -5
- package/src/hub/config-trust.ts +7 -4
- package/src/hub/control-client.ts +4 -1
- package/src/hub/daemon.ts +332 -61
- package/src/hub/dogfood-read.ts +199 -0
- package/src/hub/dogfood.ts +163 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +11 -2
- package/src/hub/facts.ts +76 -13
- package/src/hub/hub-tools.ts +45 -7
- package/src/hub/inbox-polling.ts +25 -0
- package/src/hub/instance-config.ts +17 -5
- package/src/hub/instance-execution.ts +20 -8
- package/src/hub/instances.ts +75 -2
- package/src/hub/peers.ts +9 -0
- package/src/hub/plan-conformance.ts +83 -0
- package/src/hub/profiles.ts +7 -2
- package/src/hub/release-worktree.ts +56 -0
- package/src/hub/review-brief.ts +36 -0
- package/src/hub/review-findings.ts +104 -0
- package/src/hub/roster.ts +31 -5
- package/src/hub/sdlc-queue.ts +48 -11
- package/src/hub/sdlc-release-authorization.ts +173 -0
- package/src/hub/sdlc-release.ts +16 -1
- package/src/hub/sdlc.ts +71 -18
- package/src/hub/settings.ts +10 -3
- package/src/hub/snapshots.ts +101 -14
- package/src/hub/symbol-normalizer.ts +26 -0
- package/src/hub/task-code-symbols.ts +90 -0
- package/src/hub/task-sweep.ts +3 -2
- package/src/hub/tasks.ts +556 -88
- package/src/hub/test-integrity.ts +162 -0
- package/src/hub/ui.ts +2 -2
- package/src/hub/usage-rollup.ts +361 -0
- package/src/local/sandbox.ts +5 -3
- package/src/ui/index.html +560 -78
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +44 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/dev.json +6 -1
- package/templates/profiles/general.json +6 -1
- package/templates/profiles/research.json +6 -1
- package/templates/profiles/writing.json +6 -1
- package/templates/release-guard.block.md +6 -0
package/src/hub/board.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { HOST_CODE_SYMBOLS, parseTaskCodeSymbols, type TaskCodeSymbols } from "./task-code-symbols.ts";
|
|
2
|
+
import { planFingerprint } from "./plan-conformance.ts";
|
|
1
3
|
import type { ReviewerPoolReason } from "./reviewer-pool.ts";
|
|
2
4
|
import type { GitHubSource } from "./github-import.ts";
|
|
3
5
|
import { Database } from "bun:sqlite";
|
|
@@ -6,6 +8,7 @@ import { HUB, type PeerId } from "./envelope.ts";
|
|
|
6
8
|
import type { SweepRecord } from "./task-sweep.ts";
|
|
7
9
|
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
8
10
|
import type { SdlcReceipt } from "./sdlc.ts";
|
|
11
|
+
import { parseReviewFindings, type ReviewFindingSet } from "./review-findings.ts";
|
|
9
12
|
import type { CostPlan, ReviewCost } from "./cost-governance.ts";
|
|
10
13
|
|
|
11
14
|
export const CLASSES = ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] as const;
|
|
@@ -28,7 +31,7 @@ export interface TaskRefs {
|
|
|
28
31
|
/** What an owner says it will change, before it starts (issue #31). Each list holds short free-text items. */
|
|
29
32
|
export const PLAN_KEYS = ["paths", "symbols", "signatures", "insertion_points"] as const;
|
|
30
33
|
export const DOCUMENT_PLAN_KEYS = ["paths", "sections", "sources", "claims"] as const;
|
|
31
|
-
export const ALL_PLAN_KEYS = [...PLAN_KEYS, "sections", "sources", "claims"] as const;
|
|
34
|
+
export const ALL_PLAN_KEYS = [...PLAN_KEYS, "sections", "sources", "claims", "proof", "risks"] as const;
|
|
32
35
|
export type TaskPlan = Partial<Record<(typeof ALL_PLAN_KEYS)[number], string[]>> & CostPlan;
|
|
33
36
|
/** Task outcomes older than this are pruned: demotion (issue #36) reads one week, seven half-lives. */
|
|
34
37
|
export const OUTCOMES_KEPT_MS = 7 * 24 * 3_600_000;
|
|
@@ -61,6 +64,13 @@ export interface HistoryEntry {
|
|
|
61
64
|
reviewCost?: ReviewCost;
|
|
62
65
|
/** #223 R4: on a verdict, whether the reviewer's context was new for it; set by the hub, never by the reviewer. */
|
|
63
66
|
reviewContext?: "fresh" | "continuing";
|
|
67
|
+
/** Structured review report bound to the reviewed artifact, stored in the existing history JSON. */
|
|
68
|
+
reviewFindings?: ReviewFindingSet;
|
|
69
|
+
/** Host-authored plan content revision, separate from backlog planningRevision. */
|
|
70
|
+
planRevision?: { revision: number; fingerprint: string };
|
|
71
|
+
planObservation?: { repo: string; pr: number; head: string; revision: number; fingerprint: string };
|
|
72
|
+
planDepartureInvalidated?: true;
|
|
73
|
+
planDeparture?: { generation: string; scope?: string; repo: string; pr: number; head: string; revision: number; fingerprint: string; paths: string[] };
|
|
64
74
|
reviewerFamily?: string;
|
|
65
75
|
reviewerReason?: ReviewerPoolReason;
|
|
66
76
|
reviewBlocked?: true;
|
|
@@ -96,6 +106,8 @@ export interface Task extends PlanningFields {
|
|
|
96
106
|
plan?: TaskPlan;
|
|
97
107
|
/** Artifact identities and bounded evidence, owned by Tasks (#257). */
|
|
98
108
|
sdlc?: SdlcReceipt;
|
|
109
|
+
/** Host-derived observation for one head, never accepted from task tool arguments. */
|
|
110
|
+
codeSymbols?: TaskCodeSymbols;
|
|
99
111
|
/** Tasks that must be approved before this one is offered; planning edits are limited to unstarted backlog. */
|
|
100
112
|
deps?: number[];
|
|
101
113
|
/** Who routing offers the task first once nothing is left to wait for (issue #207); null when nobody was named. */
|
|
@@ -146,6 +158,8 @@ const MOVES: Record<TaskState, TaskState[]> = {
|
|
|
146
158
|
};
|
|
147
159
|
|
|
148
160
|
const JSON_COLS = ["refs", "plan", "deps", "signals", "history", "sdlc"] as const;
|
|
161
|
+
// Separate storage keeps older SELECT * readers from exporting unscreened derived metadata.
|
|
162
|
+
const TASK_SELECT = "SELECT tasks.*, (SELECT CASE WHEN length(CAST(payload AS BLOB)) <= 65536 THEN payload END FROM task_code_symbols WHERE task=tasks.id) AS code_symbols FROM tasks";
|
|
149
163
|
|
|
150
164
|
/** Task board in `.agenthub/state/hub.db`. It outlives the hub process: `ahub kill` leaves the file. */
|
|
151
165
|
export class Board {
|
|
@@ -153,6 +167,8 @@ export class Board {
|
|
|
153
167
|
private pendingChanges: { task: Task; entry: HistoryEntry }[] | undefined;
|
|
154
168
|
/** Every recorded change, as it lands (telemetry, issue #40). */
|
|
155
169
|
onChange?: (task: Task, entry: HistoryEntry) => void;
|
|
170
|
+
/** Host lifecycle policy, installed by Tasks; off projects keep their original history shape. */
|
|
171
|
+
trackPlanRevisions?: () => boolean;
|
|
156
172
|
private changed(task: Task, entry: HistoryEntry): void {
|
|
157
173
|
if (this.pendingChanges) { this.pendingChanges.push({ task, entry }); return; }
|
|
158
174
|
try { this.onChange?.(task, entry); } catch { /* never throw after a board write */ }
|
|
@@ -214,6 +230,8 @@ export class Board {
|
|
|
214
230
|
if (!have.has("external_issue")) this.db.run("ALTER TABLE tasks ADD COLUMN external_issue INTEGER");
|
|
215
231
|
this.db.run("CREATE UNIQUE INDEX IF NOT EXISTS tasks_external_issue ON tasks(external_repo,external_issue) WHERE external_repo IS NOT NULL AND external_issue IS NOT NULL");
|
|
216
232
|
if (!have.has("sdlc")) this.db.run("ALTER TABLE tasks ADD COLUMN sdlc TEXT");
|
|
233
|
+
this.db.run("CREATE TABLE IF NOT EXISTS task_code_symbols (task INTEGER PRIMARY KEY, payload TEXT NOT NULL)");
|
|
234
|
+
this.db.run("DELETE FROM task_code_symbols WHERE task NOT IN (SELECT id FROM tasks) OR task IN (SELECT id FROM tasks WHERE json_valid(signals) AND EXISTS (SELECT 1 FROM json_each(signals) WHERE value IN ('pii','pii_path')))");
|
|
217
235
|
if (!have.has("task_role")) this.db.run("ALTER TABLE tasks ADD COLUMN task_role TEXT");
|
|
218
236
|
if (!have.has("project_profile")) this.db.run("ALTER TABLE tasks ADD COLUMN project_profile TEXT");
|
|
219
237
|
for (const col of ["rank", "priority", "release", "planned_start", "due"]) {
|
|
@@ -232,7 +250,7 @@ export class Board {
|
|
|
232
250
|
|
|
233
251
|
propose(by: PeerId, t: { title: string; detail?: string; class: TaskClass; profile?: import("./profiles.ts").ProfileName; profileClass?: string; customProfile?: true; role?: string; refs?: TaskRefs; plan?: TaskPlan; deps?: number[]; reserved?: PeerId; signals?: string[]; backlog?: boolean; external?: GitHubSource }): Task {
|
|
234
252
|
const now = Date.now();
|
|
235
|
-
const history: HistoryEntry[] = [{ at: now, by, event: t.backlog ? "backlogged" : "proposed" }];
|
|
253
|
+
const history: HistoryEntry[] = [{ at: now, by, event: t.backlog ? "backlogged" : "proposed", ...(this.trackPlanRevisions?.() && t.plan && Object.keys(t.plan).length ? { planRevision: { revision: 1, fingerprint: planFingerprint(t.plan) } } : {}) }];
|
|
236
254
|
const { lastInsertRowid } = this.taskWrite(() => this.db
|
|
237
255
|
.query("INSERT INTO tasks (title, detail, class, state, refs, plan, deps, reserved, signals, history, created, updated, project_profile, task_role, external_repo, external_issue) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
238
256
|
.run(t.title, t.detail ?? "", t.class, t.backlog ? "backlog" : "proposed", JSON.stringify(t.refs ?? {}), JSON.stringify(t.plan ?? {}), JSON.stringify(t.deps ?? []), t.reserved ?? null, JSON.stringify(t.signals ?? []), JSON.stringify(history), now, now, t.profile && (t.profile !== "dev" || t.customProfile || t.profileClass !== undefined && t.profileClass !== t.class) ? JSON.stringify({ profile: t.profile, profileClass: t.profileClass ?? t.class }) : null, t.role ?? null, t.external?.repo.toLowerCase() ?? null, t.external?.issue ?? null));
|
|
@@ -242,17 +260,17 @@ export class Board {
|
|
|
242
260
|
}
|
|
243
261
|
|
|
244
262
|
imported(repo: string, issue: number): Task | undefined {
|
|
245
|
-
const row = this.db.query(
|
|
263
|
+
const row = this.db.query(`${TASK_SELECT} WHERE external_repo=? AND external_issue=?`).get(repo.toLowerCase(), issue) as Record<string, unknown> | null;
|
|
246
264
|
return row ? parse(row) : undefined;
|
|
247
265
|
}
|
|
248
266
|
|
|
249
267
|
get(id: number): Task | undefined {
|
|
250
|
-
const row = this.db.query(
|
|
268
|
+
const row = this.db.query(`${TASK_SELECT} WHERE id = ?`).get(id) as Record<string, unknown> | null;
|
|
251
269
|
return row ? parse(row) : undefined;
|
|
252
270
|
}
|
|
253
271
|
|
|
254
272
|
list(state?: TaskState): Task[] {
|
|
255
|
-
const rows = state ? this.db.query(
|
|
273
|
+
const rows = state ? this.db.query(`${TASK_SELECT} WHERE state = ? ORDER BY id`).all(state) : this.db.query(`${TASK_SELECT} ORDER BY id`).all();
|
|
256
274
|
return (rows as Record<string, unknown>[]).map(parse);
|
|
257
275
|
}
|
|
258
276
|
|
|
@@ -307,7 +325,7 @@ export class Board {
|
|
|
307
325
|
|
|
308
326
|
/** The only way a task changes. Validates the move, records who did what, returns the new row. */
|
|
309
327
|
/** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
|
|
310
|
-
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "capacity" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number): Task {
|
|
328
|
+
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "capacity" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewFindings" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
|
|
311
329
|
const task = this.get(id);
|
|
312
330
|
if (!task) throw new Error(`no task #${id}`);
|
|
313
331
|
if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
|
|
@@ -317,16 +335,48 @@ export class Board {
|
|
|
317
335
|
throw new Error(`task #${id} is ${task.state}: cannot move to ${patch.state}`);
|
|
318
336
|
}
|
|
319
337
|
const next = { ...task, ...patch, refs: { ...task.refs, ...patch.refs } };
|
|
338
|
+
const headOf = (row: Task) => row.refs.commit ?? row.sdlc?.head ?? row.sdlc?.localHead;
|
|
339
|
+
const privateSymbols = next.signals.includes("pii") || next.signals.includes("pii_path");
|
|
340
|
+
if (Object.hasOwn(patch, "codeSymbols")) {
|
|
341
|
+
if (codeSymbolsAuthority !== HOST_CODE_SYMBOLS) throw new Error("code symbols require host authority");
|
|
342
|
+
const observation = patch.codeSymbols === undefined ? undefined : parseTaskCodeSymbols(patch.codeSymbols);
|
|
343
|
+
if (patch.codeSymbols !== undefined && !observation) throw new Error("invalid task code symbols");
|
|
344
|
+
if (observation && (privateSymbols || observation.head !== headOf(next))) throw new Error("code symbol head or privacy differs from task");
|
|
345
|
+
next.codeSymbols = observation;
|
|
346
|
+
} else if (privateSymbols || headOf(task) !== headOf(next) || task.refs.repo !== next.refs.repo || JSON.stringify(task.refs.paths) !== JSON.stringify(next.refs.paths) || task.sdlc?.repo !== next.sdlc?.repo) {
|
|
347
|
+
next.codeSymbols = undefined;
|
|
348
|
+
}
|
|
320
349
|
if (next.state === "backlog" && (next.owner || next.reviewer)) throw new Error(`task #${id}: backlog cannot have an owner or reviewer`);
|
|
321
350
|
if (!untouchedReturn) {
|
|
322
351
|
if (patch.state === "backlog" && task.owner) throw new Error(`task #${id}: owned work cannot return to backlog`);
|
|
323
352
|
}
|
|
324
353
|
if (patch.state === "backlog" && taskHasStarted(task) && !untouchedReturn) throw new Error(`task #${id}: started work cannot return to backlog`);
|
|
325
354
|
const reviewMetadata = reviewerHistory(extra);
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
355
|
+
let reviewFindings: ReviewFindingSet | undefined;
|
|
356
|
+
if (extra.reviewFindings !== undefined) {
|
|
357
|
+
if (!["approved", "changes_requested"].includes(event) || patch.state !== event) throw new Error("finding metadata requires a saved review verdict");
|
|
358
|
+
const head = extra.reviewFindings.head;
|
|
359
|
+
if (head !== undefined && !/^[a-f0-9]{40}$/.test(head)) throw new Error("finding metadata requires a full reviewed head");
|
|
360
|
+
reviewFindings = { ...(head ? { head } : {}), items: parseReviewFindings(extra.reviewFindings.items, { person: by === "user", maxNits: 64 }) };
|
|
361
|
+
}
|
|
362
|
+
if (extra.planDeparture && (by !== "user" || event !== "plan departure accepted")) throw new Error("plan departure acceptance is person-only");
|
|
363
|
+
const trackPlan = this.trackPlanRevisions?.() === true, hasDeparture = task.history.some(h => h.planDeparture);
|
|
364
|
+
const fingerprint = patch.plan && (trackPlan || hasDeparture) ? planFingerprint(next.plan ?? {}) : undefined;
|
|
365
|
+
const planChanged = fingerprint !== undefined && fingerprint !== planFingerprint(task.plan ?? {});
|
|
366
|
+
const planRevision = trackPlan && planChanged ? { revision: (task.history.findLast(h => h.planRevision)?.planRevision?.revision ?? 0) + 1, fingerprint: fingerprint! } : undefined;
|
|
367
|
+
// Disabling reporting must not let plan or repository refs edits/reverts resurrect prior consent.
|
|
368
|
+
const repositoryRefsChanged = patch.refs !== undefined && JSON.stringify([task.refs.repo, task.refs.paths]) !== JSON.stringify([next.refs.repo, next.refs.paths]);
|
|
369
|
+
const departureInvalidated = extra.planDepartureInvalidated === true || hasDeparture && (planChanged || repositoryRefsChanged);
|
|
370
|
+
const history = [...task.history, { at: Date.now(), by, event, ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}) }];
|
|
371
|
+
this.taskWrite(() => {
|
|
372
|
+
const result = this.db.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ? WHERE id = ?")
|
|
373
|
+
.run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), id);
|
|
374
|
+
if (result.changes) {
|
|
375
|
+
if (next.codeSymbols) this.db.query("INSERT INTO task_code_symbols(task,payload) VALUES(?,?) ON CONFLICT(task) DO UPDATE SET payload=excluded.payload").run(id, JSON.stringify(next.codeSymbols));
|
|
376
|
+
else this.db.query("DELETE FROM task_code_symbols WHERE task=?").run(id);
|
|
377
|
+
}
|
|
378
|
+
return result;
|
|
379
|
+
});
|
|
330
380
|
const updated = this.get(id)!;
|
|
331
381
|
this.changed(updated, history.at(-1)!);
|
|
332
382
|
return updated;
|
|
@@ -405,7 +455,10 @@ export class Board {
|
|
|
405
455
|
}
|
|
406
456
|
if (this.list().some(task => task.id !== id && !ids.has(task.id) && (task.deps?.some(dep => ids.has(dep)) || task.sdlc?.phase?.tasks.some(ref => ids.has(ref.id) || ref.after.some(dep => ids.has(dep)))))) throw new Error("split children are referenced by other work");
|
|
407
457
|
const restored = this.plan(id, by, { deps: entry!.planning!.previous.deps ?? [] }, revision, `undo split revision ${revision}`);
|
|
408
|
-
for (const child of ids) this.taskWrite(() =>
|
|
458
|
+
for (const child of ids) this.taskWrite(() => {
|
|
459
|
+
this.db.query("DELETE FROM task_code_symbols WHERE task=?").run(child);
|
|
460
|
+
return this.db.query("DELETE FROM tasks WHERE id=?").run(child);
|
|
461
|
+
});
|
|
409
462
|
return restored;
|
|
410
463
|
});
|
|
411
464
|
}
|
|
@@ -491,6 +544,14 @@ function parse(row: Record<string, unknown>): Task {
|
|
|
491
544
|
if (col === "sdlc" && row[col] == null) delete out[col];
|
|
492
545
|
else out[col] = JSON.parse(String(row[col]));
|
|
493
546
|
}
|
|
547
|
+
try {
|
|
548
|
+
const rawSymbols = row.code_symbols == null ? undefined : String(row.code_symbols);
|
|
549
|
+
const symbols = rawSymbols === undefined || Buffer.byteLength(rawSymbols) > 64 * 1024 ? undefined : parseTaskCodeSymbols(JSON.parse(rawSymbols));
|
|
550
|
+
const head = (out.refs as TaskRefs).commit ?? (out.sdlc as SdlcReceipt | undefined)?.head ?? (out.sdlc as SdlcReceipt | undefined)?.localHead;
|
|
551
|
+
const privateSymbols = (out.signals as string[]).some(signal => signal === "pii" || signal === "pii_path");
|
|
552
|
+
if (symbols && symbols.head === head && !privateSymbols) out.codeSymbols = symbols;
|
|
553
|
+
} catch { /* Malformed optional derived evidence stays unavailable. */ }
|
|
554
|
+
delete out.code_symbols;
|
|
494
555
|
if (out.project_profile != null) Object.assign(out, JSON.parse(String(out.project_profile)));
|
|
495
556
|
delete out.project_profile;
|
|
496
557
|
if (out.external_repo != null && out.external_issue != null) out.external = { provider: "github", repo: String(out.external_repo), issue: Number(out.external_issue), url: `https://github.com/${out.external_repo}/issues/${out.external_issue}` };
|
package/src/hub/budget.ts
CHANGED
|
@@ -49,6 +49,13 @@ export interface BudgetConfig {
|
|
|
49
49
|
// wait_max_min is off here, as approvals.notify is: a project config turns it on (30 unless it says otherwise).
|
|
50
50
|
export const DEFAULT_BUDGET: BudgetConfig = { gate: 0.9, stale_min: 30, poll_min: 10, checkpoint_timeout_s: 90, kimi_tokens_5h: 0, wait_max_min: 0 };
|
|
51
51
|
|
|
52
|
+
export type BudgetLedgerEntry =
|
|
53
|
+
| { type: "budget_pause"; peer: PeerId; reason: string; window: string; resetsAt: number; by: "hub" | "person" }
|
|
54
|
+
| { type: "budget_resume"; peer: PeerId; reason: string; window: string; resetsAt: number; by: "hub" | "person" }
|
|
55
|
+
| { type: "quota_reset"; peer: PeerId; window: string; cause: "resets_at_moved" | "used_dropped"; resetsAt?: number; previousResetsAt?: number; used: number; previousUsed: number; measuredAt?: string };
|
|
56
|
+
/** The window a pause record names: the id before ` window at` in its reason (`5h window at 93% (...)`), else unknown. */
|
|
57
|
+
const reasonWindow = (reason: string): string => /^(\S+) window at /.exec(reason)?.[1] ?? "unknown";
|
|
58
|
+
|
|
52
59
|
export interface BudgetHooks {
|
|
53
60
|
/** Startup-bound project resolver; undefined keeps builtin-only compatibility. */
|
|
54
61
|
definitionOf?(peer: PeerId): AdapterDefinition | undefined;
|
|
@@ -70,10 +77,23 @@ export interface BudgetHooks {
|
|
|
70
77
|
resumed(record: PauseRecord): void;
|
|
71
78
|
/** Every reading as it arrives, with the time it was measured (telemetry, issue #40). */
|
|
72
79
|
reading?(peer: PeerId, windows: UsageWindow[], hard: boolean, at: number): void;
|
|
80
|
+
/**
|
|
81
|
+
* The limit ledger (#683 U1): a pause or resume of the account, and a window reset derived from its readings. Ids, windows and
|
|
82
|
+
* numbers only. Telemetry never costs a pause or a reading, so a throwing hook is ignored.
|
|
83
|
+
*/
|
|
84
|
+
ledger?(entry: BudgetLedgerEntry): void;
|
|
73
85
|
notify(line: string): void;
|
|
74
86
|
}
|
|
75
87
|
|
|
76
88
|
const HYSTERESIS = 0.1;
|
|
89
|
+
/** A window whose `used` falls by this much inside one window id has been reset (#683 U1). */
|
|
90
|
+
const RESET_DROP = 0.5;
|
|
91
|
+
/**
|
|
92
|
+
* A provider's `resetsAt` for one window wobbles by seconds between readings (observed on the real log: 25 of 85 first-cut resets were jumps
|
|
93
|
+
* of 1 to 9 s with `used` unchanged, five more of about 10 min) while a real reset moves it by hours (the smallest of the 53 real ones: 2.4 h).
|
|
94
|
+
* A forward move of this much or less is the same window, not a reset (#683 U1).
|
|
95
|
+
*/
|
|
96
|
+
const RESET_JITTER_MS = 15 * 60_000;
|
|
77
97
|
/** Mark a pause whose handoff was a wait, and one whose wait was undone, in its reason (issue #36). */
|
|
78
98
|
const WAIT_NOTE = "; keeps its work:";
|
|
79
99
|
const WAITED_NOTE = "; waited, then the reset moved to";
|
|
@@ -90,6 +110,10 @@ export class Budget {
|
|
|
90
110
|
private readonly readings = new Map<PeerId, Map<string, Reading>>();
|
|
91
111
|
private readonly pausing = new Map<PeerId, object>();
|
|
92
112
|
private readonly handingOff = new Set<PeerId>();
|
|
113
|
+
/** The last reading of each window of an account, for the derived `quota_reset` (see `deriveResets`). */
|
|
114
|
+
private readonly lastSeen = new Map<PeerId, Map<string, { used: number; resetsAt?: number; at: number }>>();
|
|
115
|
+
/** `peer|window|resetsAt` of every reset already written this run, so a duplicate reading adds no reset. */
|
|
116
|
+
private readonly resetsRecorded = new Set<string>();
|
|
93
117
|
/** `ahub budget resume`: the owner overrode a pause; readings over the gate are ignored for that peer until then. */
|
|
94
118
|
private readonly ignoreUntil = new Map<PeerId, number>();
|
|
95
119
|
/** Chosen fallback bounds belong to a window, never to every window of the peer. */
|
|
@@ -216,6 +240,7 @@ export class Budget {
|
|
|
216
240
|
try { this.hooks.notify(`budget: ${peer} provider refused again; budget override ended`); } catch { /* a notice cannot prevent the hard pause */ }
|
|
217
241
|
}
|
|
218
242
|
try { this.hooks.reading?.(reporter, windows, hard, at); } catch { /* telemetry never costs a reading */ }
|
|
243
|
+
this.deriveResets(peer, windows, at);
|
|
219
244
|
const mine = this.readings.get(peer) ?? new Map<string, Reading>();
|
|
220
245
|
this.readings.set(peer, mine);
|
|
221
246
|
for (const w of windows) mine.set(w.id, { ...w, used: Math.max(0, Math.min(1, w.used)), at });
|
|
@@ -230,6 +255,45 @@ export class Budget {
|
|
|
230
255
|
return [...(this.readings.get(peer)?.values() ?? [])].filter((r) => this.now() - r.at <= staleMs && !(r.resetsAt && r.resetsAt <= this.now()));
|
|
231
256
|
}
|
|
232
257
|
|
|
258
|
+
/**
|
|
259
|
+
* `quota_reset` (#683 U1), derived from two readings of one window id of the account: the window's `resetsAt` moved forward (a new
|
|
260
|
+
* window began, by more than RESET_JITTER_MS), or `used` fell by RESET_DROP or more inside one window id (the provider emptied it before its stated reset). The
|
|
261
|
+
* baseline is the last reading seen, kept apart from `readings` (a resume clears those) so a reset that ended a pause is still seen.
|
|
262
|
+
* One entry per peer, window and `resetsAt`: a repeated reading, a replay after a restart of the same value, or a second member of the
|
|
263
|
+
* account adds none. A stale file reading (`at` older than the baseline) never counts: it describes an earlier window.
|
|
264
|
+
*/
|
|
265
|
+
private deriveResets(peer: PeerId, windows: UsageWindow[], at: number): void {
|
|
266
|
+
const last = this.lastSeen.get(peer) ?? new Map<string, { used: number; resetsAt?: number; at: number }>();
|
|
267
|
+
this.lastSeen.set(peer, last);
|
|
268
|
+
for (const w of windows) {
|
|
269
|
+
const used = Math.max(0, Math.min(1, w.used)), before = last.get(w.id);
|
|
270
|
+
if (before && at >= before.at) {
|
|
271
|
+
const moved = w.resetsAt !== undefined && before.resetsAt !== undefined && w.resetsAt - before.resetsAt > RESET_JITTER_MS;
|
|
272
|
+
const sameWindow = w.resetsAt === undefined || before.resetsAt === undefined || Math.abs(w.resetsAt - before.resetsAt) <= RESET_JITTER_MS;
|
|
273
|
+
const dropped = !moved && before.used - used >= RESET_DROP && sameWindow;
|
|
274
|
+
if (moved || dropped) {
|
|
275
|
+
const key = `${peer}|${w.id}|${w.resetsAt ?? "none"}`;
|
|
276
|
+
if (!this.resetsRecorded.has(key)) {
|
|
277
|
+
this.resetsRecorded.add(key);
|
|
278
|
+
this.writeLedger({ type: "quota_reset", peer, window: w.id, cause: moved ? "resets_at_moved" : "used_dropped", ...(w.resetsAt !== undefined ? { resetsAt: w.resetsAt } : {}),
|
|
279
|
+
...(before.resetsAt !== undefined ? { previousResetsAt: before.resetsAt } : {}), used, previousUsed: before.used, ...(Math.abs(this.now() - at) > 60_000 ? { measuredAt: new Date(at).toISOString() } : {}) });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (!before || at >= before.at) last.set(w.id, { used, ...(w.resetsAt !== undefined ? { resetsAt: w.resetsAt } : {}), at });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The resets already in the ledger from earlier hub runs, so a replayed reading adds none (the daemon reads them from events.jsonl). */
|
|
288
|
+
seedResets(known: Iterable<{ peer: PeerId; window: string; resetsAt?: number }>): void {
|
|
289
|
+
for (const r of known) this.resetsRecorded.add(`${r.peer}|${r.window}|${r.resetsAt ?? "none"}`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** One ledger entry; a failing hook never costs the pause, the resume or the reading it describes. */
|
|
293
|
+
private writeLedger(entry: BudgetLedgerEntry): void {
|
|
294
|
+
try { this.hooks.ledger?.(entry); } catch { /* telemetry */ }
|
|
295
|
+
}
|
|
296
|
+
|
|
233
297
|
/** The owner's override: lift the pause now and leave the peer alone until the window that caused it has reset. */
|
|
234
298
|
override(peer: PeerId): boolean {
|
|
235
299
|
peer = this.account(peer);
|
|
@@ -237,7 +301,7 @@ export class Budget {
|
|
|
237
301
|
if (!open) return false;
|
|
238
302
|
this.ignoreUntil.set(peer, open.resetsAt);
|
|
239
303
|
const bounds = this.resetBounds.get(peer);
|
|
240
|
-
this.resume(peer, "overridden by the console user");
|
|
304
|
+
this.resume(peer, "overridden by the console user", "person");
|
|
241
305
|
// A new refusal may end the override before this same window has reset.
|
|
242
306
|
if (bounds) this.resetBounds.set(peer, bounds);
|
|
243
307
|
return true;
|
|
@@ -298,6 +362,10 @@ export class Budget {
|
|
|
298
362
|
if (this.recoveryHeld) { this.deferred = true; return; }
|
|
299
363
|
if (this.closed) return; // the hub is shutting down: nothing is recorded, the next run sees the reading again
|
|
300
364
|
this.db.query("INSERT OR REPLACE INTO budget_pauses (peer, since, resets_at, reason, summary, moved) VALUES (?, ?, ?, ?, ?, ?)").run(peer, this.now(), resetsAt, reason, summary ?? null, JSON.stringify(open?.moved ?? []));
|
|
365
|
+
// A new pause that replaces an expired record (its window reset while nobody was attached to be resumed) ends that pause: without this
|
|
366
|
+
// the ledger would hold two open pauses for one peer, and a pause-duration timeline would be wrong (#683 U1).
|
|
367
|
+
if (open) this.writeLedger({ type: "budget_resume", peer, reason: "replaced by a new pause after its window reset", window: reasonWindow(open.reason), resetsAt: open.resetsAt, by: "hub" });
|
|
368
|
+
this.writeLedger({ type: "budget_pause", peer, reason, window: worst.id, resetsAt, by: "hub" });
|
|
301
369
|
this.hooks.pause(peer);
|
|
302
370
|
this.hooks.notify(`budget: ${peer} paused, ${reason}; resumes ${new Date(resetsAt).toLocaleTimeString()}${summary ? "; checkpoint received" : hard ? "; hard limit, no checkpoint" : "; no checkpoint"}`);
|
|
303
371
|
await this.finishHandoff(peer, summary);
|
|
@@ -371,11 +439,12 @@ export class Budget {
|
|
|
371
439
|
}
|
|
372
440
|
}
|
|
373
441
|
|
|
374
|
-
private resume(peer: PeerId, why: string): void {
|
|
442
|
+
private resume(peer: PeerId, why: string, by: "hub" | "person" = "hub"): void {
|
|
375
443
|
peer = this.account(peer);
|
|
376
444
|
const record = this.record(peer);
|
|
377
445
|
if (!record) return;
|
|
378
446
|
this.db.query("DELETE FROM budget_pauses WHERE peer = ?").run(peer);
|
|
447
|
+
this.writeLedger({ type: "budget_resume", peer, reason: why, window: reasonWindow(record.reason), resetsAt: record.resetsAt, by });
|
|
379
448
|
this.readings.get(peer)?.clear(); // the old numbers describe the window that just ended
|
|
380
449
|
this.resetBounds.delete(peer);
|
|
381
450
|
// The notice is queued while the peer is still paused, so it leads the first delivery: the peer learns what moved
|
package/src/hub/bus.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { appendNote, DIGEST, HUB, keepNotes, MAX_HOP, mergePrefaces, newEnvelope, noteLine, parseMarker, quoteNotes, replyAudience, uniqueMemoryLines, type Envelope, type EnvelopeOpts, type PeerId, type PeerState, type Priority } from "./envelope.ts";
|
|
1
|
+
import { appendNote, DIGEST, frame, HUB, keepNotes, MAX_HOP, mergePrefaces, newEnvelope, noteLine, parseMarker, quoteNotes, replyAudience, uniqueMemoryLines, type Envelope, type EnvelopeOpts, type PeerId, type PeerState, type Priority } from "./envelope.ts";
|
|
2
2
|
import type { PeerAdapter, DeliveryReceipt } from "./peers.ts";
|
|
3
3
|
import { DeliveryJournal, MAX_ENVELOPE_BODY, type JournalDelivery, type JournalDeliveryState } from "./delivery-journal.ts";
|
|
4
4
|
import { DELIVERY_RETRY_WARNING } from "./delivery-hints.ts";
|
|
@@ -103,6 +103,9 @@ const MAX_ATTEMPTS = 3;
|
|
|
103
103
|
// ponytail: one digest carries at most ten originals; larger supervision windows split into bounded deliveries.
|
|
104
104
|
// Revisit this ceiling after measuring native supervision cost, rather than bypassing the delivery limit.
|
|
105
105
|
const DIGEST_MAX = 10;
|
|
106
|
+
// #713: a pull-only peer reads between steps, often minutes apart, so one pull drains its queue up to this many bytes of rendered text instead of ten items.
|
|
107
|
+
// Bounded, not unbounded: a queue larger than the budget takes more than one pull, and the reply says how many are still waiting. Push peers keep DIGEST_MAX.
|
|
108
|
+
const PULL_BUDGET_BYTES = 64 * 1024;
|
|
106
109
|
const NOTES_KEPT = 10;
|
|
107
110
|
|
|
108
111
|
/**
|
|
@@ -479,7 +482,11 @@ export class Bus {
|
|
|
479
482
|
return;
|
|
480
483
|
}
|
|
481
484
|
const recipientConsent = actor === `peer:${record.peer}` && action === "retry" && record.state === "needs_review";
|
|
482
|
-
|
|
485
|
+
// #713: a `queued` delivery was never handed to the peer, so nothing is in flight and a busy peer (a pull-only TUI is busy most of the time) does not block
|
|
486
|
+
// discarding it. `dispatching` and `accepted` stay refused, and every other resolution keeps the busy fence.
|
|
487
|
+
if (["dispatching", "accepted"].includes(record.state)) throw new Error("active delivery cannot be resolved");
|
|
488
|
+
const queuedDiscard = record.state === "queued" && action === "discard";
|
|
489
|
+
if (this.peers.get(record.peer)?.state === "busy" && !recipientConsent && !queuedDiscard) throw new Error("peer busy: its delivery cannot be resolved while it works");
|
|
483
490
|
if (record.revision !== revision) throw new Error("stale delivery revision");
|
|
484
491
|
if (record.state === "queued" && action === "retry") throw new Error("queued delivery is already scheduled");
|
|
485
492
|
if (action === "retry" && (this.queues.get(record.peer)?.length ?? 0) + record.originals.length > this.opts.queueCap) throw new Error("recipient queue is full");
|
|
@@ -907,6 +914,18 @@ export class Bus {
|
|
|
907
914
|
if (this.withdrawn.has(env.id)) return;
|
|
908
915
|
const queue = this.queues.get(id) ?? [];
|
|
909
916
|
this.queues.set(id, queue);
|
|
917
|
+
// #713: a newer "task T is done, check your work" notice for this recipient replaces an earlier queued one for the same T: only the newest is useful, and the older
|
|
918
|
+
// one is discarded through the same stale/discard path with a journal outcome. Never one that already failed once or is being prepared, as for supervision.
|
|
919
|
+
const completedFor = env.from === HUB && env.kind === "task" && Number.isSafeInteger(env.refs?.completedFor) ? env.refs!.completedFor : undefined;
|
|
920
|
+
if (!front && completedFor !== undefined) {
|
|
921
|
+
for (let i = queue.length - 1; i >= 0; i--) {
|
|
922
|
+
const old = queue[i]!;
|
|
923
|
+
if (old.from !== HUB || old.refs?.completedFor !== completedFor || this.attempts.has(`${id}:${old.id}`)) continue;
|
|
924
|
+
queue.splice(i, 1);
|
|
925
|
+
this.markWithdrawn(old.id);
|
|
926
|
+
this.discardQueued(id, old, `superseded completed-change notice for task #${completedFor}`);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
910
929
|
const key = env.from === HUB && env.refs?.supervision ? env.refs.supervisionKey : undefined;
|
|
911
930
|
const replace = !front && key ? queue.findIndex((old) => old.from === HUB && old.refs?.supervision && old.refs.supervisionKey === key && !this.attempts.has(`${id}:${old.id}`)) : -1;
|
|
912
931
|
const preparing = !front && key ? this.preparing.get(id)?.find((old) => old.from === HUB && old.refs?.supervision && old.refs.supervisionKey === key && !this.attempts.has(`${id}:${old.id}`) && !this.withdrawn.has(old.id)) : undefined;
|
|
@@ -1012,7 +1031,8 @@ export class Bus {
|
|
|
1012
1031
|
const preface = this.prefaces.get(id);
|
|
1013
1032
|
if (!preface && !queue.length) return [];
|
|
1014
1033
|
const queued = [...queue];
|
|
1015
|
-
|
|
1034
|
+
// A pull-only peer drains within a byte budget (oldest first, important ones marked as they always were); an attached channel session that pulls keeps one push's worth (#493).
|
|
1035
|
+
const batch = [...(preface ? [preface] : []), ...(this.peers.get(id)?.pullOnly ? this.takeBudget(id, queue) : this.take(id, queue))];
|
|
1016
1036
|
let out: Envelope[] = [];
|
|
1017
1037
|
const deliveryId = crypto.randomUUID();
|
|
1018
1038
|
try {
|
|
@@ -1158,6 +1178,26 @@ export class Bus {
|
|
|
1158
1178
|
return batch;
|
|
1159
1179
|
}
|
|
1160
1180
|
|
|
1181
|
+
/**
|
|
1182
|
+
* #713: what one pull hands a pull-only peer. Oldest first, so the oldest ordinary item is never kept behind newer important ones (the push order is important
|
|
1183
|
+
* first; a pull is a catch-up). The budget counts each item's rendered bytes as the peer will read them (`frame`), and the first item always goes, so one huge
|
|
1184
|
+
* envelope cannot stall the queue. An envelope that already failed once still goes alone, as in `take`.
|
|
1185
|
+
*/
|
|
1186
|
+
private takeBudget(id: PeerId, queue: Envelope[]): Envelope[] {
|
|
1187
|
+
const batch: Envelope[] = [];
|
|
1188
|
+
let bytes = 0;
|
|
1189
|
+
for (const env of queue) {
|
|
1190
|
+
const failedBefore = this.attempts.has(`${id}:${env.id}`);
|
|
1191
|
+
if (failedBefore && batch.length) break;
|
|
1192
|
+
const size = Buffer.byteLength(frame(env)) + 2;
|
|
1193
|
+
if (batch.length && bytes + size > PULL_BUDGET_BYTES) break;
|
|
1194
|
+
batch.push(env); bytes += size;
|
|
1195
|
+
if (failedBefore) break;
|
|
1196
|
+
}
|
|
1197
|
+
for (const env of batch) queue.splice(queue.indexOf(env), 1);
|
|
1198
|
+
return batch;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1161
1201
|
private arm(id: PeerId, ms: number): void {
|
|
1162
1202
|
clearTimeout(this.timers.get(id));
|
|
1163
1203
|
const timer = setTimeout(() => void this.drain(id), ms);
|
package/src/hub/checks.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
1
|
import { childEnv } from "./child-process.ts";
|
|
3
2
|
|
|
4
3
|
export interface CheckResult {
|
|
@@ -10,24 +9,64 @@ export interface CheckResult {
|
|
|
10
9
|
tail: string;
|
|
11
10
|
}
|
|
12
11
|
|
|
12
|
+
/** The part of a `Bun.spawn` child a check reads; tests substitute their own. */
|
|
13
|
+
export interface CheckProcess {
|
|
14
|
+
pid: number;
|
|
15
|
+
/** Resolves with the exit code once the child is gone (128 + signal for a signalled child). */
|
|
16
|
+
exited: Promise<number>;
|
|
17
|
+
signalCode: string | null;
|
|
18
|
+
stdout: ReadableStream<Uint8Array>;
|
|
19
|
+
stderr: ReadableStream<Uint8Array>;
|
|
20
|
+
}
|
|
21
|
+
export type CheckSpawn = (argv: string[], options: { cwd: string; env: NodeJS.ProcessEnv }) => CheckProcess;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Why this is `Bun.spawn` and not `node:child_process` (#558): under `bun test` on a loaded macOS runner, node's spawn can
|
|
25
|
+
* reap a quick child inside `spawn()` itself and emit its exit and close events (and destroy its pipes, output unread)
|
|
26
|
+
* before any listener exists (oven-sh/bun#45483). The check then waited for events that had come already and returned a
|
|
27
|
+
* timeout, empty tail included, after its limit plus the kill grace. `Bun.spawn` hands over `exited` and the stream
|
|
28
|
+
* readers as values, so an early exit is still there to read. Measured under CPU load, one quick check per test file:
|
|
29
|
+
* node:child_process failed 9 of 300 rounds of 60 files, `Bun.spawn` 0 of 300.
|
|
30
|
+
*/
|
|
31
|
+
const bunSpawn: CheckSpawn = (argv, options) => Bun.spawn(argv, { cwd: options.cwd, env: options.env as Record<string, string>, stdin: "ignore", stdout: "pipe", stderr: "pipe", detached: true });
|
|
32
|
+
|
|
13
33
|
const TAIL_LINES = 40;
|
|
14
34
|
const TAIL_CHARS = 4000;
|
|
15
35
|
const KEEP_CHARS = 64 * 1024;
|
|
16
36
|
/** How long output may still arrive after the command exited; a descendant that left the group can hold the pipes. */
|
|
17
37
|
const DRAIN_MS = 500;
|
|
18
|
-
/** How long a killed check may wait for its exit and
|
|
38
|
+
/** How long a killed check may wait for its exit and its pipes before it settles without them (#558). */
|
|
19
39
|
const KILL_GRACE_MS = 1000;
|
|
20
40
|
|
|
41
|
+
/** The last lines of the output, capped by lines and characters: the one shape every result's tail has, a failed spawn's included. */
|
|
42
|
+
const tailOf = (text: string): string => text.replace(/\s+$/, "").split("\n").slice(-TAIL_LINES).join("\n").slice(-TAIL_CHARS);
|
|
43
|
+
|
|
21
44
|
/** Runs one check command in the project root, in its own process group so a timeout stops what it started. */
|
|
22
|
-
export function runCheck(command: string, cwd: string, timeoutMs: number, running?: Set<() => void>, spawnChild:
|
|
45
|
+
export function runCheck(command: string, cwd: string, timeoutMs: number, running?: Set<() => void>, spawnChild: CheckSpawn = bunSpawn): Promise<CheckResult> {
|
|
23
46
|
return new Promise((resolve) => {
|
|
24
47
|
let out = "";
|
|
25
48
|
let timedOut = false;
|
|
26
49
|
let interrupted = false;
|
|
27
50
|
let settled = false;
|
|
28
51
|
let exited = false;
|
|
29
|
-
const
|
|
52
|
+
const readers: { cancel(): Promise<unknown> }[] = [];
|
|
30
53
|
let grace: ReturnType<typeof setTimeout> | undefined;
|
|
54
|
+
let drain: ReturnType<typeof setTimeout> | undefined;
|
|
55
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
56
|
+
let child: CheckProcess;
|
|
57
|
+
const stopListening = () => {
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
clearTimeout(grace);
|
|
60
|
+
clearTimeout(drain);
|
|
61
|
+
for (const reader of readers) reader.cancel().catch(() => {});
|
|
62
|
+
};
|
|
63
|
+
const done = (code: number | null) => {
|
|
64
|
+
if (settled) return;
|
|
65
|
+
settled = true;
|
|
66
|
+
stopListening();
|
|
67
|
+
running?.delete(stop);
|
|
68
|
+
resolve({ code: timedOut || interrupted ? null : code, timedOut, ...(interrupted ? { interrupted } : {}), tail: tailOf(out) });
|
|
69
|
+
};
|
|
31
70
|
const killGroup = () => {
|
|
32
71
|
try {
|
|
33
72
|
if (child.pid) process.kill(-child.pid, "SIGKILL");
|
|
@@ -35,51 +74,61 @@ export function runCheck(command: string, cwd: string, timeoutMs: number, runnin
|
|
|
35
74
|
// already gone
|
|
36
75
|
}
|
|
37
76
|
};
|
|
38
|
-
/** A kill that is a verdict (timeout, hub stop) settles even if the child's
|
|
77
|
+
/** A kill that is a verdict (timeout, hub stop) settles even if the child's exit and pipes never report. */
|
|
39
78
|
const killAndSettle = () => {
|
|
40
79
|
killGroup();
|
|
41
80
|
grace ??= setTimeout(() => done(null), KILL_GRACE_MS);
|
|
42
81
|
};
|
|
43
|
-
|
|
44
|
-
if (exited || settled) return; // finished
|
|
82
|
+
function stop() {
|
|
83
|
+
if (exited || settled) return; // finished in time or already settled: its result stands and the group may be reused
|
|
45
84
|
interrupted = true;
|
|
46
85
|
killAndSettle();
|
|
47
|
-
}
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
child = spawnChild(["sh", "-c", command], { cwd, env: childEnv(process.env) });
|
|
89
|
+
} catch (e) {
|
|
90
|
+
resolve({ code: null, timedOut: false, tail: tailOf(e instanceof Error ? e.message : String(e)) });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
48
93
|
running?.add(stop);
|
|
49
94
|
const keep = (chunk: string) => {
|
|
50
95
|
out += chunk;
|
|
51
96
|
if (out.length > KEEP_CHARS) out = out.slice(-KEEP_CHARS);
|
|
52
97
|
};
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
98
|
+
/** Reads one pipe to its end; resolves when it closes, or when `done` cancels it. */
|
|
99
|
+
const pump = (stream: ReadableStream<Uint8Array>): Promise<void> => {
|
|
100
|
+
const reader = stream.getReader();
|
|
101
|
+
readers.push(reader);
|
|
102
|
+
const decoder = new TextDecoder(); // decodes across chunk edges, so Korean output is not cut into U+FFFD
|
|
103
|
+
return (async () => {
|
|
104
|
+
try {
|
|
105
|
+
for (;;) {
|
|
106
|
+
const { value, done: ended } = await reader.read();
|
|
107
|
+
if (ended) break;
|
|
108
|
+
keep(decoder.decode(value, { stream: true }));
|
|
109
|
+
}
|
|
110
|
+
keep(decoder.decode());
|
|
111
|
+
} catch {
|
|
112
|
+
// cancelled or broken: what was read stands
|
|
113
|
+
}
|
|
114
|
+
})();
|
|
115
|
+
};
|
|
116
|
+
const pipes = Promise.all([pump(child.stdout), pump(child.stderr)]);
|
|
117
|
+
timer = setTimeout(() => {
|
|
58
118
|
timedOut = true;
|
|
59
119
|
killAndSettle();
|
|
60
120
|
}, timeoutMs);
|
|
61
|
-
|
|
62
|
-
if (settled) return;
|
|
63
|
-
settled = true;
|
|
64
|
-
clearTimeout(timer);
|
|
65
|
-
clearTimeout(grace);
|
|
66
|
-
running?.delete(stop);
|
|
67
|
-
child.stdout?.destroy();
|
|
68
|
-
child.stderr?.destroy();
|
|
69
|
-
const tail = out.replace(/\s+$/, "").split("\n").slice(-TAIL_LINES).join("\n").slice(-TAIL_CHARS);
|
|
70
|
-
resolve({ code: timedOut || interrupted ? null : code, timedOut, ...(interrupted ? { interrupted } : {}), tail });
|
|
71
|
-
};
|
|
72
|
-
child.on("error", (e) => {
|
|
73
|
-
out += `\n${e.message}`;
|
|
74
|
-
done(null);
|
|
75
|
-
});
|
|
76
|
-
child.on("exit", (code) => {
|
|
121
|
+
child.exited.then((code) => {
|
|
77
122
|
if (settled) return; // the kill grace settled it: the process group id is no longer ours to signal
|
|
78
123
|
exited = true;
|
|
79
124
|
clearTimeout(timer); // it finished in time; only its output may still be on the way
|
|
80
125
|
killGroup(); // a background job it left behind (sh has no job control, so it is still in the group)
|
|
81
|
-
|
|
126
|
+
const result = child.signalCode ? null : code;
|
|
127
|
+
drain = setTimeout(() => done(result), DRAIN_MS);
|
|
128
|
+
void pipes.then(() => done(result)); // every pipe closed: nothing more is coming
|
|
129
|
+
}, (e: unknown) => {
|
|
130
|
+
out += `\n${e instanceof Error ? e.message : String(e)}`;
|
|
131
|
+
done(null);
|
|
82
132
|
});
|
|
83
|
-
child.on("close", (code) => done(code));
|
|
84
133
|
});
|
|
85
134
|
}
|