@staix/agent-hub 0.12.45 → 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 +18 -0
- package/README.md +83 -116
- package/docs/agent-notes/README.md +6 -0
- package/docs/agent-notes/adapters.md +10 -7
- package/docs/agent-notes/budget.md +6 -1
- package/docs/agent-notes/bus.md +6 -4
- package/docs/agent-notes/daemon.md +6 -1
- package/docs/agent-notes/local-worker.md +2 -1
- package/docs/agent-notes/tasks.md +10 -0
- package/docs/agent-notes/tests.md +4 -2
- package/docs/dogfood.md +92 -0
- package/docs/events.md +14 -1
- package/docs/operations.md +250 -19
- package/docs/profiles.md +2 -2
- package/docs/quickstart.md +8 -2
- package/docs/sdlc.md +36 -6
- package/docs/security.md +20 -11
- package/docs/smoke.md +9 -4
- package/docs/specs/2026-09-19-agent-hub-design.md +759 -8
- package/docs/verification/2026-10-19-reviewer-isolation.md +2 -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/dogfood/2026-10-22.md +3109 -0
- package/docs/verified.json +14 -14
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +613 -329
- package/src/adapters/agy/entry.ts +5 -4
- package/src/adapters/claude-channel.ts +20 -9
- package/src/adapters/codex-appserver.ts +18 -2
- package/src/adapters/codex-review-isolation.ts +12 -5
- package/src/adapters/compat.ts +140 -0
- package/src/adapters/definitions/builtin-metadata.ts +2 -1
- 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-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/qwen-native.ts +7 -3
- package/src/adapters/definitions/qwen.ts +2 -1
- package/src/adapters/jcode.ts +72 -31
- package/src/adapters/kimi-review.ts +6 -3
- package/src/adapters/registry-types.ts +3 -1
- package/src/adapters/registry.ts +1 -0
- 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 +16 -6
- package/src/cli/identity.ts +29 -6
- package/src/cli/init.ts +109 -11
- package/src/cli/main.ts +122 -11
- 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/hub/action-class.ts +362 -0
- package/src/hub/action-policy.ts +106 -0
- package/src/hub/board.ts +43 -8
- 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 +10 -1
- package/src/hub/config-trust.ts +7 -4
- package/src/hub/daemon.ts +237 -22
- 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/hub-tools.ts +24 -2
- package/src/hub/inbox-polling.ts +25 -0
- package/src/hub/instances.ts +73 -1
- 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/sdlc-queue.ts +31 -9
- package/src/hub/sdlc-release-authorization.ts +173 -0
- package/src/hub/sdlc.ts +49 -11
- package/src/hub/settings.ts +5 -2
- package/src/hub/snapshots.ts +93 -8
- 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 +292 -54
- package/src/hub/test-integrity.ts +162 -0
- package/src/hub/usage-rollup.ts +361 -0
- package/src/local/sandbox.ts +5 -3
- package/src/ui/index.html +1 -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
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { ACTION_CLASSES, type ActionClass } from "./action-class.ts";
|
|
2
|
+
import { sanitize } from "./envelope.ts";
|
|
3
|
+
|
|
4
|
+
export interface ActionPolicyConfig { enabled: boolean; always_ask: ActionClass[]; budgets: Record<ActionClass, number> }
|
|
5
|
+
const DEFAULT_BUDGETS: Record<ActionClass, number> = { publish: 1, destroy: 3, "external-write": 5, install: 2 };
|
|
6
|
+
/** Defaults are host policy; overrides belong only in untracked config.local.json.
|
|
7
|
+
* ponytail: startup adopts that local policy without a retained person confirmation.
|
|
8
|
+
* Before adapter enforcement (#312/#313), bind weakened startup policy to person
|
|
9
|
+
* confirmation or surface it as attention; slice 1 never enforces these grants.
|
|
10
|
+
*/
|
|
11
|
+
export function actionPolicyConfig(value?: unknown): ActionPolicyConfig {
|
|
12
|
+
const fail = (): never => { throw new Error("action_policy: expected enabled, always_ask and class budgets (integers 0..1000)"); };
|
|
13
|
+
if (value === undefined) return { enabled: true, always_ask: [...ACTION_CLASSES], budgets: { ...DEFAULT_BUDGETS } };
|
|
14
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return fail();
|
|
15
|
+
const raw = value as Record<string, unknown>;
|
|
16
|
+
if (Object.keys(raw).some(key => !["enabled", "always_ask", "budgets"].includes(key))) return fail();
|
|
17
|
+
if (raw.enabled !== undefined && typeof raw.enabled !== "boolean") return fail();
|
|
18
|
+
const classes = raw.always_ask === undefined ? ACTION_CLASSES : raw.always_ask;
|
|
19
|
+
if (!Array.isArray(classes) || classes.length > ACTION_CLASSES.length || new Set(classes).size !== classes.length || classes.some(key => !ACTION_CLASSES.includes(key))) return fail();
|
|
20
|
+
const budgets = { ...DEFAULT_BUDGETS };
|
|
21
|
+
if (raw.budgets !== undefined) {
|
|
22
|
+
if (!raw.budgets || typeof raw.budgets !== "object" || Array.isArray(raw.budgets)) return fail();
|
|
23
|
+
for (const [key, amount] of Object.entries(raw.budgets)) {
|
|
24
|
+
if (!ACTION_CLASSES.includes(key as ActionClass) || typeof amount !== "number" || !Number.isSafeInteger(amount) || amount < 0 || amount > 1000) return fail();
|
|
25
|
+
budgets[key as ActionClass] = amount;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return { enabled: raw.enabled ?? true, always_ask: [...classes], budgets } as ActionPolicyConfig;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ActionSessionBudget { readonly peer: string; readonly session: string }
|
|
32
|
+
export interface ActionChallenge { readonly classes: readonly ActionClass[] }
|
|
33
|
+
export type ActionDecision = { kind: "native" } | { kind: "ask"; classes: ActionClass[]; challenge?: ActionChallenge } | { kind: "budget"; classes: ActionClass[] };
|
|
34
|
+
interface Session { handle: ActionSessionBudget; policy: string; remaining: Record<ActionClass, number> }
|
|
35
|
+
const empty = (): Record<ActionClass, number> => ({ publish: 0, destroy: 0, "external-write": 0, install: 0 });
|
|
36
|
+
/** Host-only ledger: session ids and policy come from current runtime ports, never a tool argument. */
|
|
37
|
+
export class ActionBudgets {
|
|
38
|
+
private sessions = new Map<string, Session>();
|
|
39
|
+
private challenges = new WeakMap<ActionChallenge, { handle: ActionSessionBudget; policy: string; classes: ActionClass[] }>();
|
|
40
|
+
constructor(private readonly config: () => ActionPolicyConfig | undefined, private readonly currentSession: (peer: string) => string | undefined) {}
|
|
41
|
+
bindSession(peer: string, session: string): ActionSessionBudget | undefined {
|
|
42
|
+
if (!session || this.currentSession(peer) !== session) return undefined;
|
|
43
|
+
const previous = this.sessions.get(peer);
|
|
44
|
+
if (previous?.handle.session === session) return previous.handle;
|
|
45
|
+
const handle = Object.freeze({ peer, session });
|
|
46
|
+
this.sessions.set(peer, { handle, policy: "", remaining: empty() });
|
|
47
|
+
return handle;
|
|
48
|
+
}
|
|
49
|
+
revoke(peer: string): void { this.sessions.delete(peer); }
|
|
50
|
+
policyChanged(): void {
|
|
51
|
+
for (const state of this.sessions.values()) { state.policy = ""; state.remaining = empty(); }
|
|
52
|
+
this.challenges = new WeakMap();
|
|
53
|
+
}
|
|
54
|
+
private current(handle: ActionSessionBudget | undefined): Session | undefined {
|
|
55
|
+
if (!handle) return undefined;
|
|
56
|
+
const state = this.sessions.get(handle.peer);
|
|
57
|
+
if (state?.handle !== handle || this.currentSession(handle.peer) !== handle.session) return undefined;
|
|
58
|
+
const policy = JSON.stringify(actionPolicyConfig(this.config()));
|
|
59
|
+
if (state.policy !== policy) { state.policy = policy; state.remaining = empty(); }
|
|
60
|
+
return state;
|
|
61
|
+
}
|
|
62
|
+
private selected(actions: readonly ActionClass[]): ActionClass[] {
|
|
63
|
+
const config = actionPolicyConfig(this.config());
|
|
64
|
+
return config.enabled ? ACTION_CLASSES.filter(key => actions.includes(key) && config.always_ask.includes(key)) : [];
|
|
65
|
+
}
|
|
66
|
+
/** A budget decision consumes every selected class atomically; native never grants permission. */
|
|
67
|
+
request(handle: ActionSessionBudget | undefined, actions: readonly ActionClass[]): ActionDecision {
|
|
68
|
+
const state = this.current(handle), classes = this.selected(actions);
|
|
69
|
+
if (!classes.length) return { kind: "native" };
|
|
70
|
+
if (!state) return { kind: "ask", classes };
|
|
71
|
+
if (classes.some(key => state.remaining[key] <= 0)) {
|
|
72
|
+
const challenge = Object.freeze({ classes: Object.freeze([...classes]) });
|
|
73
|
+
this.challenges.set(challenge, { handle: state.handle, policy: state.policy, classes: [...classes] });
|
|
74
|
+
return { kind: "ask", classes, challenge };
|
|
75
|
+
}
|
|
76
|
+
for (const key of classes) state.remaining[key]--;
|
|
77
|
+
return { kind: "budget", classes };
|
|
78
|
+
}
|
|
79
|
+
/** Explicit class-budget approval only, never an ordinary allow_once; this invocation consumes slot one. */
|
|
80
|
+
approve(challenge: ActionChallenge | undefined): boolean {
|
|
81
|
+
if (!challenge) return false;
|
|
82
|
+
const grant = this.challenges.get(challenge); this.challenges.delete(challenge);
|
|
83
|
+
if (!grant) return false;
|
|
84
|
+
const state = this.current(grant.handle), config = actionPolicyConfig(this.config());
|
|
85
|
+
if (!state || state.policy !== grant.policy) return false;
|
|
86
|
+
for (const key of grant.classes) state.remaining[key] = Math.max(0, config.budgets[key] - 1);
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
/** Read-only projection: no session is created, rebound or reset by a status poll. */
|
|
90
|
+
status(peer: string, enforced: boolean) {
|
|
91
|
+
const config = actionPolicyConfig(this.config()), state = this.sessions.get(peer);
|
|
92
|
+
const bound = !!state && this.currentSession(peer) === state.handle.session && state.policy === JSON.stringify(config);
|
|
93
|
+
return { enabled: config.enabled, alwaysAsk: [...config.always_ask], limits: { ...config.budgets }, remaining: bound ? { ...state.remaining } : empty(), sessionBound: bound,
|
|
94
|
+
enforceable: enforced, note: !enforced ? `always-ask not enforceable: adapter integration pending${config.enabled ? "" : "; policy disabled"}` : config.enabled ? "always-ask active" : "always-ask disabled" };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Adapter integrations must pass the host's publicView and current command/private-turn screens. */
|
|
99
|
+
export function actionApprovalText<T>(classes: readonly ActionClass[], command: string, task: T | undefined,
|
|
100
|
+
ports: { publicView(task: T): Record<string, unknown>; private: boolean; commandView(command: string): string | undefined }): string {
|
|
101
|
+
const view = task === undefined ? undefined : ports.publicView(task);
|
|
102
|
+
const title = typeof view?.title === "string" ? sanitize(view.title).replace(/[\p{Cc}\p{Cf}]/gu, " ").slice(0, 160) : undefined;
|
|
103
|
+
const shown = ports.private ? undefined : ports.commandView(command);
|
|
104
|
+
const line = shown === undefined ? "[private command withheld]" : sanitize(shown).replace(/[\p{Cc}\p{Cf}]/gu, " ").slice(0, 512);
|
|
105
|
+
return `Action approval (${ACTION_CLASSES.filter(key => classes.includes(key)).join(", ")})${title ? `; task ${title}` : ""}: ${line}`;
|
|
106
|
+
}
|
package/src/hub/board.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { HOST_CODE_SYMBOLS, parseTaskCodeSymbols, type TaskCodeSymbols } from "./task-code-symbols.ts";
|
|
1
2
|
import { planFingerprint } from "./plan-conformance.ts";
|
|
2
3
|
import type { ReviewerPoolReason } from "./reviewer-pool.ts";
|
|
3
4
|
import type { GitHubSource } from "./github-import.ts";
|
|
@@ -105,6 +106,8 @@ export interface Task extends PlanningFields {
|
|
|
105
106
|
plan?: TaskPlan;
|
|
106
107
|
/** Artifact identities and bounded evidence, owned by Tasks (#257). */
|
|
107
108
|
sdlc?: SdlcReceipt;
|
|
109
|
+
/** Host-derived observation for one head, never accepted from task tool arguments. */
|
|
110
|
+
codeSymbols?: TaskCodeSymbols;
|
|
108
111
|
/** Tasks that must be approved before this one is offered; planning edits are limited to unstarted backlog. */
|
|
109
112
|
deps?: number[];
|
|
110
113
|
/** Who routing offers the task first once nothing is left to wait for (issue #207); null when nobody was named. */
|
|
@@ -155,6 +158,8 @@ const MOVES: Record<TaskState, TaskState[]> = {
|
|
|
155
158
|
};
|
|
156
159
|
|
|
157
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";
|
|
158
163
|
|
|
159
164
|
/** Task board in `.agenthub/state/hub.db`. It outlives the hub process: `ahub kill` leaves the file. */
|
|
160
165
|
export class Board {
|
|
@@ -225,6 +230,8 @@ export class Board {
|
|
|
225
230
|
if (!have.has("external_issue")) this.db.run("ALTER TABLE tasks ADD COLUMN external_issue INTEGER");
|
|
226
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");
|
|
227
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')))");
|
|
228
235
|
if (!have.has("task_role")) this.db.run("ALTER TABLE tasks ADD COLUMN task_role TEXT");
|
|
229
236
|
if (!have.has("project_profile")) this.db.run("ALTER TABLE tasks ADD COLUMN project_profile TEXT");
|
|
230
237
|
for (const col of ["rank", "priority", "release", "planned_start", "due"]) {
|
|
@@ -253,17 +260,17 @@ export class Board {
|
|
|
253
260
|
}
|
|
254
261
|
|
|
255
262
|
imported(repo: string, issue: number): Task | undefined {
|
|
256
|
-
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;
|
|
257
264
|
return row ? parse(row) : undefined;
|
|
258
265
|
}
|
|
259
266
|
|
|
260
267
|
get(id: number): Task | undefined {
|
|
261
|
-
const row = this.db.query(
|
|
268
|
+
const row = this.db.query(`${TASK_SELECT} WHERE id = ?`).get(id) as Record<string, unknown> | null;
|
|
262
269
|
return row ? parse(row) : undefined;
|
|
263
270
|
}
|
|
264
271
|
|
|
265
272
|
list(state?: TaskState): Task[] {
|
|
266
|
-
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();
|
|
267
274
|
return (rows as Record<string, unknown>[]).map(parse);
|
|
268
275
|
}
|
|
269
276
|
|
|
@@ -318,7 +325,7 @@ export class Board {
|
|
|
318
325
|
|
|
319
326
|
/** The only way a task changes. Validates the move, records who did what, returns the new row. */
|
|
320
327
|
/** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
|
|
321
|
-
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" | "reviewFindings" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | 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 {
|
|
322
329
|
const task = this.get(id);
|
|
323
330
|
if (!task) throw new Error(`no task #${id}`);
|
|
324
331
|
if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
|
|
@@ -328,6 +335,17 @@ export class Board {
|
|
|
328
335
|
throw new Error(`task #${id} is ${task.state}: cannot move to ${patch.state}`);
|
|
329
336
|
}
|
|
330
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
|
+
}
|
|
331
349
|
if (next.state === "backlog" && (next.owner || next.reviewer)) throw new Error(`task #${id}: backlog cannot have an owner or reviewer`);
|
|
332
350
|
if (!untouchedReturn) {
|
|
333
351
|
if (patch.state === "backlog" && task.owner) throw new Error(`task #${id}: owned work cannot return to backlog`);
|
|
@@ -350,9 +368,15 @@ export class Board {
|
|
|
350
368
|
const repositoryRefsChanged = patch.refs !== undefined && JSON.stringify([task.refs.repo, task.refs.paths]) !== JSON.stringify([next.refs.repo, next.refs.paths]);
|
|
351
369
|
const departureInvalidated = extra.planDepartureInvalidated === true || hasDeparture && (planChanged || repositoryRefsChanged);
|
|
352
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 } : {}) }];
|
|
353
|
-
this.taskWrite(() =>
|
|
354
|
-
.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ? WHERE id = ?")
|
|
355
|
-
|
|
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
|
+
});
|
|
356
380
|
const updated = this.get(id)!;
|
|
357
381
|
this.changed(updated, history.at(-1)!);
|
|
358
382
|
return updated;
|
|
@@ -431,7 +455,10 @@ export class Board {
|
|
|
431
455
|
}
|
|
432
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");
|
|
433
457
|
const restored = this.plan(id, by, { deps: entry!.planning!.previous.deps ?? [] }, revision, `undo split revision ${revision}`);
|
|
434
|
-
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
|
+
});
|
|
435
462
|
return restored;
|
|
436
463
|
});
|
|
437
464
|
}
|
|
@@ -517,6 +544,14 @@ function parse(row: Record<string, unknown>): Task {
|
|
|
517
544
|
if (col === "sdlc" && row[col] == null) delete out[col];
|
|
518
545
|
else out[col] = JSON.parse(String(row[col]));
|
|
519
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;
|
|
520
555
|
if (out.project_profile != null) Object.assign(out, JSON.parse(String(out.project_profile)));
|
|
521
556
|
delete out.project_profile;
|
|
522
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
|
}
|