@staix/agent-hub 0.12.45 → 0.14.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 +31 -0
- package/README.md +85 -116
- package/docs/agent-notes/README.md +6 -0
- package/docs/agent-notes/adapters.md +10 -7
- package/docs/agent-notes/budget.md +7 -1
- package/docs/agent-notes/bus.md +6 -4
- package/docs/agent-notes/daemon.md +14 -2
- package/docs/agent-notes/local-worker.md +2 -1
- package/docs/agent-notes/tasks.md +16 -3
- package/docs/agent-notes/tests.md +5 -3
- package/docs/dogfood.md +95 -0
- package/docs/events.md +14 -1
- package/docs/operations.md +290 -27
- package/docs/profiles.md +2 -2
- package/docs/quickstart.md +13 -3
- package/docs/sdlc.md +59 -8
- package/docs/security.md +26 -13
- package/docs/smoke.md +9 -4
- package/docs/specs/2026-09-19-agent-hub-design.md +797 -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 +61 -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/2026-10-23-0.14.0.md +69 -0
- package/docs/verification/dogfood/2026-10-22.md +3109 -0
- package/docs/verified.json +22 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +702 -347
- 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 +29 -11
- package/src/adapters/compat.ts +140 -0
- package/src/adapters/definitions/builtin-metadata.ts +3 -2
- package/src/adapters/definitions/native-cli-doctor.ts +12 -5
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
- 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/adapters/reviewer-print.ts +34 -7
- 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 +17 -6
- package/src/cli/identity.ts +29 -6
- package/src/cli/init.ts +109 -11
- package/src/cli/main.ts +127 -11
- package/src/cli/output.ts +91 -3
- package/src/cli/preview.ts +1 -1
- package/src/cli/sdlc-release-guard.ts +82 -0
- package/src/cli/status-lines.ts +1 -1
- package/src/hub/action-class.ts +362 -0
- package/src/hub/action-policy.ts +106 -0
- package/src/hub/board.ts +65 -9
- 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 +319 -34
- 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 +26 -3
- package/src/hub/inbox-polling.ts +25 -0
- package/src/hub/instances.ts +87 -5
- package/src/hub/manager.ts +2 -2
- 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-metrics.ts +260 -0
- package/src/hub/sdlc-queue.ts +31 -9
- package/src/hub/sdlc-release-authorization.ts +173 -0
- package/src/hub/sdlc.ts +54 -14
- package/src/hub/settings.ts +5 -2
- package/src/hub/snapshots.ts +93 -8
- package/src/hub/spec-read.ts +31 -0
- package/src/hub/symbol-normalizer.ts +26 -0
- package/src/hub/task-code-group.ts +52 -0
- package/src/hub/task-code-symbols.ts +90 -0
- package/src/hub/task-sweep.ts +3 -2
- package/src/hub/tasks.ts +455 -67
- package/src/hub/test-integrity.ts +162 -0
- package/src/hub/ui.ts +3 -0
- package/src/hub/usage-rollup.ts +419 -0
- package/src/local/sandbox.ts +5 -3
- package/src/ui/index.html +274 -2
- 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 {
|
|
@@ -217,6 +222,8 @@ export class Board {
|
|
|
217
222
|
// And from before issue #207: existing rows reserve nobody.
|
|
218
223
|
for (const col of ["offer", "cancelled"]) if (!have.has(col)) this.db.run(`ALTER TABLE tasks ADD COLUMN ${col} TEXT`);
|
|
219
224
|
this.db.run("CREATE TABLE IF NOT EXISTS task_stalls (task INTEGER PRIMARY KEY, payload TEXT NOT NULL)");
|
|
225
|
+
// #481 S7: one row per log-tier band breach episode already announced, so a restart never repeats the notice.
|
|
226
|
+
this.db.run("CREATE TABLE IF NOT EXISTS band_notices (marker TEXT PRIMARY KEY, at INTEGER NOT NULL)");
|
|
220
227
|
// Derived status-cache generation: only Board task/stall writes advance it, never journal writes.
|
|
221
228
|
this.db.run("CREATE TABLE IF NOT EXISTS board_status_revision (id INTEGER PRIMARY KEY CHECK(id=1), revision INTEGER NOT NULL)");
|
|
222
229
|
this.db.run("INSERT OR IGNORE INTO board_status_revision VALUES (1,0)");
|
|
@@ -225,6 +232,8 @@ export class Board {
|
|
|
225
232
|
if (!have.has("external_issue")) this.db.run("ALTER TABLE tasks ADD COLUMN external_issue INTEGER");
|
|
226
233
|
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
234
|
if (!have.has("sdlc")) this.db.run("ALTER TABLE tasks ADD COLUMN sdlc TEXT");
|
|
235
|
+
this.db.run("CREATE TABLE IF NOT EXISTS task_code_symbols (task INTEGER PRIMARY KEY, payload TEXT NOT NULL)");
|
|
236
|
+
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
237
|
if (!have.has("task_role")) this.db.run("ALTER TABLE tasks ADD COLUMN task_role TEXT");
|
|
229
238
|
if (!have.has("project_profile")) this.db.run("ALTER TABLE tasks ADD COLUMN project_profile TEXT");
|
|
230
239
|
for (const col of ["rank", "priority", "release", "planned_start", "due"]) {
|
|
@@ -253,17 +262,17 @@ export class Board {
|
|
|
253
262
|
}
|
|
254
263
|
|
|
255
264
|
imported(repo: string, issue: number): Task | undefined {
|
|
256
|
-
const row = this.db.query(
|
|
265
|
+
const row = this.db.query(`${TASK_SELECT} WHERE external_repo=? AND external_issue=?`).get(repo.toLowerCase(), issue) as Record<string, unknown> | null;
|
|
257
266
|
return row ? parse(row) : undefined;
|
|
258
267
|
}
|
|
259
268
|
|
|
260
269
|
get(id: number): Task | undefined {
|
|
261
|
-
const row = this.db.query(
|
|
270
|
+
const row = this.db.query(`${TASK_SELECT} WHERE id = ?`).get(id) as Record<string, unknown> | null;
|
|
262
271
|
return row ? parse(row) : undefined;
|
|
263
272
|
}
|
|
264
273
|
|
|
265
274
|
list(state?: TaskState): Task[] {
|
|
266
|
-
const rows = state ? this.db.query(
|
|
275
|
+
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
276
|
return (rows as Record<string, unknown>[]).map(parse);
|
|
268
277
|
}
|
|
269
278
|
|
|
@@ -293,6 +302,11 @@ export class Board {
|
|
|
293
302
|
this.taskWrite(() => this.db.query("INSERT INTO task_stalls(task,payload) VALUES(?,?) ON CONFLICT(task) DO UPDATE SET payload=excluded.payload").run(stall.task, JSON.stringify(stall)));
|
|
294
303
|
return true;
|
|
295
304
|
}
|
|
305
|
+
/** True when the marker was new (and is now recorded), false when it was announced before. Ids, metric names and task numbers only. */
|
|
306
|
+
recordBandNotice(marker: string): boolean {
|
|
307
|
+
if (!/^[a-z_]{1,64}:#[1-9][0-9]{0,9}:log$/.test(marker)) throw new Error("invalid band notice marker");
|
|
308
|
+
return this.db.query("INSERT OR IGNORE INTO band_notices(marker, at) VALUES(?, ?)").run(marker, Date.now()).changes > 0;
|
|
309
|
+
}
|
|
296
310
|
clearStall(task: number): void { this.taskWrite(() => this.db.query("DELETE FROM task_stalls WHERE task=?").run(task)); }
|
|
297
311
|
stalls(): TaskStall[] {
|
|
298
312
|
return (this.db.query("SELECT payload FROM task_stalls ORDER BY task").all() as { payload: string }[]).flatMap(row => {
|
|
@@ -318,7 +332,7 @@ export class Board {
|
|
|
318
332
|
|
|
319
333
|
/** The only way a task changes. Validates the move, records who did what, returns the new row. */
|
|
320
334
|
/** 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 {
|
|
335
|
+
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
336
|
const task = this.get(id);
|
|
323
337
|
if (!task) throw new Error(`no task #${id}`);
|
|
324
338
|
if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
|
|
@@ -328,6 +342,17 @@ export class Board {
|
|
|
328
342
|
throw new Error(`task #${id} is ${task.state}: cannot move to ${patch.state}`);
|
|
329
343
|
}
|
|
330
344
|
const next = { ...task, ...patch, refs: { ...task.refs, ...patch.refs } };
|
|
345
|
+
const headOf = (row: Task) => row.refs.commit ?? row.sdlc?.head ?? row.sdlc?.localHead;
|
|
346
|
+
const privateSymbols = next.signals.includes("pii") || next.signals.includes("pii_path");
|
|
347
|
+
if (Object.hasOwn(patch, "codeSymbols")) {
|
|
348
|
+
if (codeSymbolsAuthority !== HOST_CODE_SYMBOLS) throw new Error("code symbols require host authority");
|
|
349
|
+
const observation = patch.codeSymbols === undefined ? undefined : parseTaskCodeSymbols(patch.codeSymbols);
|
|
350
|
+
if (patch.codeSymbols !== undefined && !observation) throw new Error("invalid task code symbols");
|
|
351
|
+
if (observation && (privateSymbols || observation.head !== headOf(next))) throw new Error("code symbol head or privacy differs from task");
|
|
352
|
+
next.codeSymbols = observation;
|
|
353
|
+
} 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) {
|
|
354
|
+
next.codeSymbols = undefined;
|
|
355
|
+
}
|
|
331
356
|
if (next.state === "backlog" && (next.owner || next.reviewer)) throw new Error(`task #${id}: backlog cannot have an owner or reviewer`);
|
|
332
357
|
if (!untouchedReturn) {
|
|
333
358
|
if (patch.state === "backlog" && task.owner) throw new Error(`task #${id}: owned work cannot return to backlog`);
|
|
@@ -350,9 +375,15 @@ export class Board {
|
|
|
350
375
|
const repositoryRefsChanged = patch.refs !== undefined && JSON.stringify([task.refs.repo, task.refs.paths]) !== JSON.stringify([next.refs.repo, next.refs.paths]);
|
|
351
376
|
const departureInvalidated = extra.planDepartureInvalidated === true || hasDeparture && (planChanged || repositoryRefsChanged);
|
|
352
377
|
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
|
-
|
|
378
|
+
this.taskWrite(() => {
|
|
379
|
+
const result = this.db.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ? WHERE id = ?")
|
|
380
|
+
.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);
|
|
381
|
+
if (result.changes) {
|
|
382
|
+
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));
|
|
383
|
+
else this.db.query("DELETE FROM task_code_symbols WHERE task=?").run(id);
|
|
384
|
+
}
|
|
385
|
+
return result;
|
|
386
|
+
});
|
|
356
387
|
const updated = this.get(id)!;
|
|
357
388
|
this.changed(updated, history.at(-1)!);
|
|
358
389
|
return updated;
|
|
@@ -379,12 +410,26 @@ export class Board {
|
|
|
379
410
|
const task = this.get(id);
|
|
380
411
|
if (!task || (task.planningRevision ?? 0) !== revision || task.state !== "backlog" || task.owner || !taskCanPlan(task)) throw new Error("planner pull changed or already started");
|
|
381
412
|
const now = Date.now(), entry: HistoryEntry = { at: now, by, event: "backlog pulled", planning: { revision: revision + 1, fields: ["reserved"], previous: { reserved: task.reserved ?? null }, pull: true }, note, ...(withheld ? { withheld: true as const } : {}) };
|
|
382
|
-
const result = this.taskWrite(() => this.db.query("UPDATE tasks SET state='proposed',owner=NULL,reviewer=NULL,reserved=?,planning_revision=planning_revision+1,history=?,updated=? WHERE id=? AND planning_revision=? AND state='backlog' AND owner IS NULL")
|
|
413
|
+
const result = this.taskWrite(() => this.db.query("UPDATE tasks SET state='proposed',owner=NULL,reviewer=NULL,offer=NULL,reserved=?,planning_revision=planning_revision+1,history=?,updated=? WHERE id=? AND planning_revision=? AND state='backlog' AND owner IS NULL")
|
|
383
414
|
.run(reserved, JSON.stringify([...task.history, entry]), now, id, revision));
|
|
384
415
|
if (result.changes !== 1) throw new Error("planner pull changed");
|
|
385
416
|
const updated = this.get(id)!; this.changed(updated, entry); return updated;
|
|
386
417
|
}
|
|
387
418
|
|
|
419
|
+
/** Publish no intermediate ownerless pull: Tasks has already admitted the exact reserved peer. */
|
|
420
|
+
pullAssigned(id: number, by: PeerId, owner: PeerId, revision: number, note: string, withheld: boolean,
|
|
421
|
+
reviewer: PeerId | null, assignmentNote: string, extra: Parameters<Board["update"]>[5], expectedFlowRevision: number): Task {
|
|
422
|
+
return this.atomic(() => {
|
|
423
|
+
const current = this.get(id);
|
|
424
|
+
if (!current || current.history.length !== expectedFlowRevision) throw new Error("task flow revision changed");
|
|
425
|
+
const pulled = this.pullPlanned(id, by, owner, revision, note, withheld);
|
|
426
|
+
const assigned = this.update(id, by, "assigned", { owner, reviewer }, assignmentNote, extra, pulled.history.length);
|
|
427
|
+
// Both deferred events describe the committed handoff, including the callback payload.
|
|
428
|
+
for (const change of this.pendingChanges ?? []) if (change.task.id === id) change.task = assigned;
|
|
429
|
+
return assigned;
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
388
433
|
/** No owner or execution evidence may be taken back by planning undo. */
|
|
389
434
|
undoPlannedPull(id: number, by: PeerId, revision: number): Task {
|
|
390
435
|
const task = this.get(id), pull = task ? planningUndoEntry(task)?.planning : undefined;
|
|
@@ -431,7 +476,10 @@ export class Board {
|
|
|
431
476
|
}
|
|
432
477
|
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
478
|
const restored = this.plan(id, by, { deps: entry!.planning!.previous.deps ?? [] }, revision, `undo split revision ${revision}`);
|
|
434
|
-
for (const child of ids) this.taskWrite(() =>
|
|
479
|
+
for (const child of ids) this.taskWrite(() => {
|
|
480
|
+
this.db.query("DELETE FROM task_code_symbols WHERE task=?").run(child);
|
|
481
|
+
return this.db.query("DELETE FROM tasks WHERE id=?").run(child);
|
|
482
|
+
});
|
|
435
483
|
return restored;
|
|
436
484
|
});
|
|
437
485
|
}
|
|
@@ -517,6 +565,14 @@ function parse(row: Record<string, unknown>): Task {
|
|
|
517
565
|
if (col === "sdlc" && row[col] == null) delete out[col];
|
|
518
566
|
else out[col] = JSON.parse(String(row[col]));
|
|
519
567
|
}
|
|
568
|
+
try {
|
|
569
|
+
const rawSymbols = row.code_symbols == null ? undefined : String(row.code_symbols);
|
|
570
|
+
const symbols = rawSymbols === undefined || Buffer.byteLength(rawSymbols) > 64 * 1024 ? undefined : parseTaskCodeSymbols(JSON.parse(rawSymbols));
|
|
571
|
+
const head = (out.refs as TaskRefs).commit ?? (out.sdlc as SdlcReceipt | undefined)?.head ?? (out.sdlc as SdlcReceipt | undefined)?.localHead;
|
|
572
|
+
const privateSymbols = (out.signals as string[]).some(signal => signal === "pii" || signal === "pii_path");
|
|
573
|
+
if (symbols && symbols.head === head && !privateSymbols) out.codeSymbols = symbols;
|
|
574
|
+
} catch { /* Malformed optional derived evidence stays unavailable. */ }
|
|
575
|
+
delete out.code_symbols;
|
|
520
576
|
if (out.project_profile != null) Object.assign(out, JSON.parse(String(out.project_profile)));
|
|
521
577
|
delete out.project_profile;
|
|
522
578
|
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);
|