@staix/agent-hub 0.21.0 → 0.22.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 +7 -0
- package/README.md +2 -2
- package/docs/agent-notes/bus.md +1 -1
- package/docs/agent-notes/daemon.md +2 -2
- package/docs/agent-notes/tasks.md +1 -1
- package/docs/agent-notes/tests.md +1 -1
- package/docs/dogfood.md +3 -1
- package/docs/operations.md +45 -0
- package/docs/profiles.md +1 -1
- package/docs/quickstart.md +5 -1
- package/docs/smoke.md +6 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +31 -0
- package/docs/verification/2026-10-30-0.22.0.md +49 -0
- package/docs/verification/2026-10-30-445-capability-probes.md +172 -0
- package/docs/verification/2026-10-30-bus-delivery-operational-settings.md +77 -0
- package/docs/verification/2026-10-30-jcode-ac4.md +24 -0
- package/docs/verification/2026-10-30-jcode-ac6.md +52 -0
- package/docs/verification/2026-10-30-recovery-wait-settings.md +141 -0
- package/docs/verified.json +12 -12
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1 -1
- package/src/cli/help.ts +1 -0
- package/src/cli/identity.ts +3 -1
- package/src/cli/learn-output.ts +38 -0
- package/src/cli/main.ts +20 -2
- package/src/cli/recovery-waits.ts +26 -0
- package/src/cli/smoke-pty-bridge.ts +67 -18
- package/src/cli/smoke-pty.ts +10 -2
- package/src/cli/upgrade-runtime.ts +16 -14
- package/src/cli/upgrade.ts +2 -1
- package/src/hub/bus.ts +18 -10
- package/src/hub/config-apply.ts +2 -1
- package/src/hub/daemon.ts +61 -14
- package/src/hub/delivery-journal.ts +78 -57
- package/src/hub/learning.ts +33 -1
- package/src/hub/operational-settings.ts +79 -1
- package/src/hub/settings.ts +21 -1
- package/src/hub/tasks.ts +78 -1
package/src/hub/bus.ts
CHANGED
|
@@ -41,6 +41,11 @@ export interface BusOptions {
|
|
|
41
41
|
/** How long a status envelope may wait for company before it is delivered on its own. */
|
|
42
42
|
batchMs: number;
|
|
43
43
|
queueCap: number;
|
|
44
|
+
/** Accepted live operational settings; read only at the next matching delivery/note operation. */
|
|
45
|
+
maxAttempts?: () => number;
|
|
46
|
+
digestMax?: () => number;
|
|
47
|
+
pullBudgetBytes?: () => number;
|
|
48
|
+
notesKept?: () => number;
|
|
44
49
|
/** Optional: rewrite a delivery before it goes out (M6 digest condensation). Must return its input on any failure. */
|
|
45
50
|
condense?: (envs: Envelope[]) => Promise<Envelope[]>;
|
|
46
51
|
journal?: DeliveryJournal;
|
|
@@ -118,7 +123,7 @@ function resolveTo(to: PeerId[], originals: Envelope[] | undefined): PeerId[] {
|
|
|
118
123
|
}
|
|
119
124
|
|
|
120
125
|
const SEEN_CAP = 2048;
|
|
121
|
-
const MAX_ATTEMPTS = 3;
|
|
126
|
+
export const MAX_ATTEMPTS = 3;
|
|
122
127
|
// ponytail: provenance is capped at 2048 ids/peer, 4096 peers, 100000 ids total and 256 KiB per snapshot.
|
|
123
128
|
// Omitted metadata means unknown age only; use a measured per-envelope store if larger queues need provenance.
|
|
124
129
|
const QUEUE_TIME_CAP = 2048, QUEUE_TIME_PEERS_CAP = 4096, QUEUE_TIME_TOTAL_CAP = 100_000, QUEUE_TIME_SNAPSHOT_BYTES = 256 * 1024;
|
|
@@ -127,11 +132,11 @@ const remoteId = (id: string): boolean => id.includes("@");
|
|
|
127
132
|
const remoteFingerprint = (value: unknown): value is string => typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
|
|
128
133
|
// ponytail: one digest carries at most ten originals; larger supervision windows split into bounded deliveries.
|
|
129
134
|
// Revisit this ceiling after measuring native supervision cost, rather than bypassing the delivery limit.
|
|
130
|
-
const DIGEST_MAX = 10;
|
|
135
|
+
export const DIGEST_MAX = 10;
|
|
131
136
|
// #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.
|
|
132
137
|
// 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.
|
|
133
|
-
const PULL_BUDGET_BYTES = 64 * 1024;
|
|
134
|
-
const NOTES_KEPT = 10;
|
|
138
|
+
export const PULL_BUDGET_BYTES = 64 * 1024;
|
|
139
|
+
export const NOTES_KEPT = 10;
|
|
135
140
|
|
|
136
141
|
/**
|
|
137
142
|
* N-peer fan-out. Never delivers back to `from`, caps hops, dedupes by id. Each peer has one queue,
|
|
@@ -797,7 +802,7 @@ export class Bus {
|
|
|
797
802
|
note(id: PeerId, line: string): void {
|
|
798
803
|
if (remoteId(id)) return;
|
|
799
804
|
const existing = this.prefaces.get(id);
|
|
800
|
-
this.prefaces.set(id, existing ? { ...existing, body: keepNotes(uniqueMemoryLines(appendNote(existing.body, line)), NOTES_KEPT) } : newEnvelope(HUB, uniqueMemoryLines(line), { to: [id], kind: "presence" }));
|
|
805
|
+
this.prefaces.set(id, existing ? { ...existing, body: keepNotes(uniqueMemoryLines(appendNote(existing.body, line)), this.opts.notesKept?.() ?? NOTES_KEPT) } : newEnvelope(HUB, uniqueMemoryLines(line), { to: [id], kind: "presence" }));
|
|
801
806
|
this.persist();
|
|
802
807
|
}
|
|
803
808
|
|
|
@@ -805,7 +810,7 @@ export class Bus {
|
|
|
805
810
|
private restorePreface(id: PeerId, env: Envelope): void {
|
|
806
811
|
if (remoteId(id)) return;
|
|
807
812
|
const now = this.prefaces.get(id);
|
|
808
|
-
this.prefaces.set(id, !now ? { ...env, body: uniqueMemoryLines(env.body) } : now.id === env.id ? { ...now, body: uniqueMemoryLines(now.body) } : { ...env, body: keepNotes(mergePrefaces(env.body, now.body), NOTES_KEPT) });
|
|
813
|
+
this.prefaces.set(id, !now ? { ...env, body: uniqueMemoryLines(env.body) } : now.id === env.id ? { ...now, body: uniqueMemoryLines(now.body) } : { ...env, body: keepNotes(mergePrefaces(env.body, now.body), this.opts.notesKept?.() ?? NOTES_KEPT) });
|
|
809
814
|
}
|
|
810
815
|
|
|
811
816
|
/**
|
|
@@ -1505,13 +1510,14 @@ export class Bus {
|
|
|
1505
1510
|
* An envelope that already failed once goes alone, so a poison one cannot take a digest down with it.
|
|
1506
1511
|
*/
|
|
1507
1512
|
private take(id: PeerId, queue: Envelope[], remove = true): Envelope[] {
|
|
1513
|
+
const digestMax = this.opts.digestMax?.() ?? DIGEST_MAX;
|
|
1508
1514
|
const ordered = [...queue.filter((e) => e.priority === "important"), ...queue.filter((e) => e.priority !== "important")];
|
|
1509
1515
|
const batch: Envelope[] = [];
|
|
1510
1516
|
for (const env of ordered) {
|
|
1511
1517
|
const failedBefore = this.attempts.has(`${id}:${env.id}`);
|
|
1512
1518
|
if (failedBefore && batch.length) break;
|
|
1513
1519
|
batch.push(env);
|
|
1514
|
-
if (failedBefore || remoteId(id) || batch.length ===
|
|
1520
|
+
if (failedBefore || remoteId(id) || batch.length === digestMax) break;
|
|
1515
1521
|
}
|
|
1516
1522
|
if (remove) for (const env of batch) queue.splice(queue.indexOf(env), 1);
|
|
1517
1523
|
return batch;
|
|
@@ -1523,13 +1529,14 @@ export class Bus {
|
|
|
1523
1529
|
* envelope cannot stall the queue. An envelope that already failed once still goes alone, as in `take`.
|
|
1524
1530
|
*/
|
|
1525
1531
|
private takeBudget(id: PeerId, queue: Envelope[]): Envelope[] {
|
|
1532
|
+
const pullBudgetBytes = this.opts.pullBudgetBytes?.() ?? PULL_BUDGET_BYTES;
|
|
1526
1533
|
const batch: Envelope[] = [];
|
|
1527
1534
|
let bytes = 0;
|
|
1528
1535
|
for (const env of queue) {
|
|
1529
1536
|
const failedBefore = this.attempts.has(`${id}:${env.id}`);
|
|
1530
1537
|
if (failedBefore && batch.length) break;
|
|
1531
1538
|
const size = Buffer.byteLength(frame(env)) + 2;
|
|
1532
|
-
if (batch.length && bytes + size >
|
|
1539
|
+
if (batch.length && bytes + size > pullBudgetBytes) break;
|
|
1533
1540
|
batch.push(env); bytes += size;
|
|
1534
1541
|
if (failedBefore) break;
|
|
1535
1542
|
}
|
|
@@ -1555,11 +1562,12 @@ export class Bus {
|
|
|
1555
1562
|
}
|
|
1556
1563
|
|
|
1557
1564
|
private failed(id: PeerId, envs: Envelope[], reason = "delivery failed without an error detail", quota = false): void {
|
|
1565
|
+
const maxAttempts = this.opts.maxAttempts?.() ?? MAX_ATTEMPTS;
|
|
1558
1566
|
if (!this.journal) this.deliveryFailed(id);
|
|
1559
1567
|
// A quota refusal ran no work. The budget pause fences retries until reset; it is not a poison attempt.
|
|
1560
1568
|
const quotaPaused = quota && this.paused.has(id);
|
|
1561
1569
|
this.lastFailure.set(id, reason.replace(/\s+/g, " ").slice(0, 300));
|
|
1562
|
-
if (!quotaPaused && envs.some((env) => !(env.from === HUB && env.kind === "presence") && (this.attempts.get(`${id}:${env.id}`) ?? 0) + 1 >=
|
|
1570
|
+
if (!quotaPaused && envs.some((env) => !(env.from === HUB && env.kind === "presence") && (this.attempts.get(`${id}:${env.id}`) ?? 0) + 1 >= maxAttempts)) {
|
|
1563
1571
|
this.failureStreak.set(id, (this.failureStreak.get(id) ?? 0) + 1);
|
|
1564
1572
|
}
|
|
1565
1573
|
const keep = envs.filter((env) => {
|
|
@@ -1571,7 +1579,7 @@ export class Bus {
|
|
|
1571
1579
|
if (quotaPaused) return true;
|
|
1572
1580
|
const key = `${id}:${env.id}`;
|
|
1573
1581
|
const n = (this.attempts.get(key) ?? 0) + 1;
|
|
1574
|
-
if (n <
|
|
1582
|
+
if (n < maxAttempts) return this.attempts.set(key, n);
|
|
1575
1583
|
this.attempts.delete(key);
|
|
1576
1584
|
this.emit({ t: "undeliverable", env, peer: id, reason: this.lastFailure.get(id) });
|
|
1577
1585
|
return false;
|
package/src/hub/config-apply.ts
CHANGED
|
@@ -194,6 +194,7 @@ const schema: Record<string, Check> = {
|
|
|
194
194
|
conductor: block({ feed: words(["own", "all", "off"]), approval_wait_s: number(0, 2_147_483_647 / 1000) }),
|
|
195
195
|
budget: block({ gate: number(0, 1), stale_min: timerMinutes, poll_min: timerMinutes, checkpoint_timeout_s: timerSeconds, kimi_tokens_5h: number(0), wait_max_min: number(0) }),
|
|
196
196
|
context: block({ gate: number(0, 1), stale_min: timerMinutes, max_assignment_used: number(0, 1) }),
|
|
197
|
+
bus: block({ max_attempts: operationalNumberCheck("bus.max_attempts"), digest_max: operationalNumberCheck("bus.digest_max"), pull_budget_bytes: operationalNumberCheck("bus.pull_budget_bytes"), notes_kept: operationalNumberCheck("bus.notes_kept"), terminal_history_limit: operationalNumberCheck("bus.terminal_history_limit") }),
|
|
197
198
|
inference: block({ enabled: bool, digest_min_items: number(1, 100_000, true), digest_min_chars: number(1, 10_000_000, true), triage: bool }),
|
|
198
199
|
omniroute: block({ urls: strings, api_key_file: text, access_hosts: strings, cf_client_id_file: text, cf_client_secret_file: text }),
|
|
199
200
|
mlx: block({ enabled: bool, provider: words(["legacy", "ollama"]), host: text, runtimeDir: text, modelPath: text, bin: text, port: number(1, 65535, true), model: text, sourceModel: text,
|
|
@@ -213,7 +214,7 @@ const schema: Record<string, Check> = {
|
|
|
213
214
|
peers: (v, k) => { map(block({ start_mode: words(["tui", "headless"]) }), START_MODE_PEERS)(v, k); peerStartConfig(v); },
|
|
214
215
|
terminal: (v, k) => { block({ open: strings })(v, k); terminalTemplate(object(v, k).open); },
|
|
215
216
|
limits: block({ sender_per_min: number(0), pair_per_min: number(0), important_per_hour: number(0), repeat_window_s: number(0) }),
|
|
216
|
-
review: block({ adaptive: bool, min_reviews: number(1, Number.MAX_SAFE_INTEGER, true), pool: v => { reviewerPoolConfig({ pool: v }, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS); }, rerotate: bool, same_family: words(["fallback", "never"]) }), recovery: block({ auto_resume_after_crash: bool }),
|
|
217
|
+
review: block({ adaptive: bool, min_reviews: number(1, Number.MAX_SAFE_INTEGER, true), pool: v => { reviewerPoolConfig({ pool: v }, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS); }, rerotate: bool, same_family: words(["fallback", "never"]) }), recovery: block({ auto_resume_after_crash: bool, reconnect_wait_s: operationalNumberCheck("recovery.reconnect_wait_s"), unmanaged_reconnect_wait_s: operationalNumberCheck("recovery.unmanaged_reconnect_wait_s"), control_timeout_s: operationalNumberCheck("recovery.control_timeout_s"), shutdown_wait_s: operationalNumberCheck("recovery.shutdown_wait_s"), terminal_idle_wait_s: operationalNumberCheck("recovery.terminal_idle_wait_s") }),
|
|
217
218
|
capabilities: map(strings), coordination: words(["advisory", "turn-free"]), experiments: block({ stale_notices: words(["drop", "deliver"]) }),
|
|
218
219
|
};
|
|
219
220
|
export const SCHEMA_KEYS: readonly string[] = Object.freeze(Object.keys(schema));
|
package/src/hub/daemon.ts
CHANGED
|
@@ -81,6 +81,7 @@ import { childEnv } from "./child-process.ts";
|
|
|
81
81
|
import { runCheck } from "./checks.ts";
|
|
82
82
|
import { DEFAULT_TASK_SWEEP, taskSweepConfig, type TaskSweepConfig } from "./task-sweep.ts";
|
|
83
83
|
import { CONTRADICTION_WINDOW_SETTING, DEMOTE_AT_SETTING, DEMOTION_HALF_LIFE_SETTING, GROOM_FYI_WINDOW_SETTING, RESCREEN_MAX_SETTING, SPLIT_FAILURE_SHARE_SETTING, SPLIT_MIN_SETTING, taskOperationalReaders } from "./operational-settings.ts";
|
|
84
|
+
import { BUS_DIGEST_MAX_SETTING, BUS_MAX_ATTEMPTS_SETTING, BUS_NOTES_KEPT_SETTING, BUS_PULL_BUDGET_BYTES_SETTING, BUS_TERMINAL_HISTORY_LIMIT_SETTING, busOperationalReaders, RECOVERY_CONTROL_TIMEOUT_SETTING, RECOVERY_RECONNECT_WAIT_SETTING, RECOVERY_SHUTDOWN_WAIT_SETTING, RECOVERY_TERMINAL_IDLE_WAIT_SETTING, RECOVERY_UNMANAGED_RECONNECT_WAIT_SETTING } from "./operational-settings.ts";
|
|
84
85
|
import { foldersConfig, scheduleViews, schedulesConfig, ScheduleStore, type FolderEntry, type ScheduleEntry } from "./schedules.ts";
|
|
85
86
|
import { FolderStore, registerScanned, scanFolder, type FolderPolicy } from "./folder-trigger.ts";
|
|
86
87
|
import type { ScannedFile } from "./schedules.ts";
|
|
@@ -155,7 +156,8 @@ import { AssignmentUndeliveredError, ESCALATE_AFTER, Tasks } from "./tasks.ts";
|
|
|
155
156
|
import { DEFAULT_INFERENCE, DIGEST, Inference, screenPii, type InferenceConfig, type PiiVerdict } from "./inference.ts";
|
|
156
157
|
import { taskEvidencePrivacy, type TaskEvidencePrivacy, ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
|
|
157
158
|
import { currentRouting, loadRouting, detectSignals, OVERLAY_FILE, overlayToml, parseRouting, routingText, type Routing } from "./routing.ts";
|
|
158
|
-
import {
|
|
159
|
+
import { withChecklistLine } from "./learning.ts";
|
|
160
|
+
import { LEARN_CHECKLIST, NEVER_EDITABLE, projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUndoIdentity, readOverlay, restoredValue, routingCandidate, storedLocally, sharedValue, settingDef as instanceSettingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingSource, type SettingDef, type SettingRow, type SettingValue, type UndoIdentity } from "./settings.ts";
|
|
159
161
|
import { githubOriginRepository } from "./task-repository.ts";
|
|
160
162
|
import { Bus } from "./bus.ts";
|
|
161
163
|
import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
|
|
@@ -219,6 +221,8 @@ export interface HubConfig {
|
|
|
219
221
|
batch_max: number;
|
|
220
222
|
batch_ms: number;
|
|
221
223
|
queue_cap: number;
|
|
224
|
+
/** #489 H3: accepted delivery policy, read at the next delivery operation without rewriting queued work. */
|
|
225
|
+
bus?: { max_attempts?: number; digest_max?: number; pull_budget_bytes?: number; notes_kept?: number; terminal_history_limit?: number };
|
|
222
226
|
/** `native` (#258): the hub's own store in ~/.agenthub/memory, on with any project config; `short_ttl_days`: short-term lifetime after approval; `sync_interval_min`: machine sync while a remote is set (default 10, 0 off). */
|
|
223
227
|
memory: { enabled: boolean; worker_url?: string; inject_tokens: number; brief_items: number; native?: boolean; short_ttl_days?: number; sync_interval_min?: number; suggest_edges?: boolean };
|
|
224
228
|
roster: PeerRoster;
|
|
@@ -269,7 +273,8 @@ export interface HubConfig {
|
|
|
269
273
|
/** Reviewer choice from recorded review outcomes, once a reviewer has `min_reviews` of an implementer (issue #35). */
|
|
270
274
|
review: { adaptive: boolean; min_reviews: number; pool?: string[]; rerotate?: boolean; same_family?: "fallback" | "never" };
|
|
271
275
|
/** After an unplanned stop, start Kimi, Pi and the local worker again with their recorded sessions (issue #37). */
|
|
272
|
-
|
|
276
|
+
/** #489 H4: the five waits the upgrade coordinator reads from this project's config at the start of each wait; the daemon itself only validates and carries them. */
|
|
277
|
+
recovery: { auto_resume_after_crash: boolean; reconnect_wait_s?: number; unmanaged_reconnect_wait_s?: number; control_timeout_s?: number; shutdown_wait_s?: number; terminal_idle_wait_s?: number };
|
|
273
278
|
/** Per peer, the hub-tool capabilities it has (issue #39); a peer not listed has all of them. */
|
|
274
279
|
capabilities: Record<string, string[]>;
|
|
275
280
|
/**
|
|
@@ -297,6 +302,7 @@ export const DEFAULT_CONFIG: HubConfig = {
|
|
|
297
302
|
batch_max: 3,
|
|
298
303
|
batch_ms: 15_000,
|
|
299
304
|
queue_cap: 200,
|
|
305
|
+
bus: { max_attempts: BUS_MAX_ATTEMPTS_SETTING.defaultValue, digest_max: BUS_DIGEST_MAX_SETTING.defaultValue, pull_budget_bytes: BUS_PULL_BUDGET_BYTES_SETTING.defaultValue, notes_kept: BUS_NOTES_KEPT_SETTING.defaultValue, terminal_history_limit: BUS_TERMINAL_HISTORY_LIMIT_SETTING.defaultValue },
|
|
300
306
|
memory: { enabled: true, inject_tokens: 2000, brief_items: 8 },
|
|
301
307
|
roster: {},
|
|
302
308
|
roles: DEFAULT_ROLES,
|
|
@@ -324,7 +330,7 @@ export const DEFAULT_CONFIG: HubConfig = {
|
|
|
324
330
|
terminal: { open: [] },
|
|
325
331
|
limits: DEFAULT_LIMITS,
|
|
326
332
|
review: { adaptive: false, min_reviews: 5 },
|
|
327
|
-
recovery: { auto_resume_after_crash: false },
|
|
333
|
+
recovery: { auto_resume_after_crash: false, reconnect_wait_s: RECOVERY_RECONNECT_WAIT_SETTING.defaultValue, unmanaged_reconnect_wait_s: RECOVERY_UNMANAGED_RECONNECT_WAIT_SETTING.defaultValue, control_timeout_s: RECOVERY_CONTROL_TIMEOUT_SETTING.defaultValue, shutdown_wait_s: RECOVERY_SHUTDOWN_WAIT_SETTING.defaultValue, terminal_idle_wait_s: RECOVERY_TERMINAL_IDLE_WAIT_SETTING.defaultValue },
|
|
328
334
|
capabilities: {},
|
|
329
335
|
coordination: "advisory",
|
|
330
336
|
};
|
|
@@ -339,7 +345,7 @@ const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
|
339
345
|
/** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
|
|
340
346
|
const CONFIG_FILES = INSTANCE_CONFIG_FILES;
|
|
341
347
|
export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<string, any> }
|
|
342
|
-
const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "corpus", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "pet"];
|
|
348
|
+
const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "bus", "corpus", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "pet"];
|
|
343
349
|
|
|
344
350
|
/** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
|
|
345
351
|
export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[], scope: Pick<ReturnType<typeof readInstanceConfig>, "instances" | "catalog"> = readInstanceConfig(cwd, layers)): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string | string[]; notices?: string[] } {
|
|
@@ -532,6 +538,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
532
538
|
notify: typeof file.approvals?.notify === "boolean" ? file.approvals.notify : process.platform === "darwin",
|
|
533
539
|
},
|
|
534
540
|
tasks: { ...DEFAULT_CONFIG.tasks, ...file.tasks, ...(file.tasks?.wip_limits !== undefined ? { wip_limits: planningWipLimits(file.tasks.wip_limits) } : {}) },
|
|
541
|
+
bus: { ...DEFAULT_CONFIG.bus, ...file.bus },
|
|
535
542
|
task_sweep: taskSweepConfig(file.task_sweep),
|
|
536
543
|
schedules: schedulesConfig(file.schedules),
|
|
537
544
|
folder_triggers: foldersConfig(file.folder_triggers),
|
|
@@ -911,6 +918,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
911
918
|
const found = instanceSettingDef(key, declarations, adapterCatalog), stale = staleRosterKey(key);
|
|
912
919
|
return typeof found === "string" && stale ? { key: `roster.${stale}`, group: "Roster", label: `${stale}: stale roster key (instance no longer declared)`, type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["roster", stale] } : found;
|
|
913
920
|
};
|
|
921
|
+
// #514b: undo resolves the managed checklist key, which is not a settings row, and every other key through the project-scoped lookup above (declared instances, the adapter catalog, a stale roster key).
|
|
922
|
+
const undoableDef = (key: unknown): SettingDef | string => key === LEARN_CHECKLIST.key ? LEARN_CHECKLIST : settingDef(key);
|
|
914
923
|
const accountOf = (peer: string) => definitionOf(peer)?.budget?.account ?? peer;
|
|
915
924
|
const accountMembers = (account: string) => SUPPORTED_PEERS.filter(peer => accountOf(peer) === account);
|
|
916
925
|
let rosterChanging: string | undefined;
|
|
@@ -1172,7 +1181,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1172
1181
|
|
|
1173
1182
|
// The hub's own model calls (digest condensation, task triage) are wired below, once the gateway client exists.
|
|
1174
1183
|
let inference: Inference | undefined;
|
|
1175
|
-
const
|
|
1184
|
+
const { terminalHistoryLimit, ...busReaders } = busOperationalReaders(() => config);
|
|
1185
|
+
const journal = new DeliveryJournal({ file: join(opts.stateDir, "hub.db"), projectRoot: opts.cwd, projectId, instanceId, operationId: recoveryOperation, terminalHistoryLimit });
|
|
1176
1186
|
// What the crash left in flight, per recipient: opening the journal just marked these needs_review.
|
|
1177
1187
|
const lost = new Map<PeerId, JournalDelivery[]>();
|
|
1178
1188
|
if (crashed) for (const d of journal.list()) if (d.state === "needs_review" && d.reason === "daemon stopped during delivery" && d.updatedAt >= startedAt) lost.set(d.peer, [...(lost.get(d.peer) ?? []), d]);
|
|
@@ -1292,7 +1302,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1292
1302
|
const queuePolicy = () => queueBackpressurePolicy(config.tasks.queue_backpressure);
|
|
1293
1303
|
let remotePending = (_room: string): readonly string[] => [];
|
|
1294
1304
|
let remoteQueueChanged = () => {};
|
|
1295
|
-
const bus: Bus = new Bus({ rostered: membershipAllowed, trackQueuePressure: () => { const policy = queuePolicy(); return policy.max_queued > 0 || policy.max_oldest_s > 0; }, deliveryHeld: peer => requestedPeerStops.has(peer) || rosterChanging === peer, journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit, relevant: (peer, env) => reviewOnlyPeer(peer) && !reviewOnlyItem(peer, env) ? `not for ${peer}: a review-only instance receives review requests only` : env.refs?.boundedCheck || env.refs?.supervision || env.refs?.backlog ? relevantNotice(peer, env) : staleOff() || relevantNotice(peer, env), silence,
|
|
1305
|
+
const bus: Bus = new Bus({ rostered: membershipAllowed, ...busReaders, trackQueuePressure: () => { const policy = queuePolicy(); return policy.max_queued > 0 || policy.max_oldest_s > 0; }, deliveryHeld: peer => requestedPeerStops.has(peer) || rosterChanging === peer, journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit, relevant: (peer, env) => reviewOnlyPeer(peer) && !reviewOnlyItem(peer, env) ? `not for ${peer}: a review-only instance receives review requests only` : env.refs?.boundedCheck || env.refs?.supervision || env.refs?.backlog ? relevantNotice(peer, env) : staleOff() || relevantNotice(peer, env), silence,
|
|
1296
1306
|
remotePending: room => remotePending(room),
|
|
1297
1307
|
remotePrivate: (env): boolean => {
|
|
1298
1308
|
const task = env.refs?.task;
|
|
@@ -3432,6 +3442,33 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3432
3442
|
return JSON.stringify((await tasks.approveIntake(by, a.id)).map(task => tasks.publicView(task)));
|
|
3433
3443
|
case "intake_discard":
|
|
3434
3444
|
return JSON.stringify((await tasks.discardIntake(by, a.id)).map(task => tasks.publicView(task)));
|
|
3445
|
+
// #514b: the person's verdict on a recurring-criterion candidate. Person only (Tasks refuses any other `by`, and the generic gate above already refuses every non-person caller, the configured
|
|
3446
|
+
// conductor included: none of these is in CONDUCTOR_TASK_OPS). Accept writes ONE key, derived from the card's key line, through the settings writer.
|
|
3447
|
+
case "learn_list":
|
|
3448
|
+
return JSON.stringify(tasks.learnCandidates(by, { all: a.all === true }));
|
|
3449
|
+
case "learn_reject":
|
|
3450
|
+
return JSON.stringify(tasks.publicView(await tasks.learnClose(by, a.id, "rejected")));
|
|
3451
|
+
case "learn_accept": {
|
|
3452
|
+
// A card the person already accepted answers with a success that writes nothing: no config, no undo record, no event. A rejected or otherwise closed card falls through and is refused.
|
|
3453
|
+
const decided = tasks.learnAccepted(by, a.id);
|
|
3454
|
+
if (decided) return JSON.stringify({ card: tasks.publicView(board.get(decided.id)!), accepted: { profile: decided.profile, class: decided.class, criterion: decided.criterion, alreadyAccepted: true, file: ".agenthub/config.local.json" } });
|
|
3455
|
+
const plan = tasks.learnAcceptPlan(by, a.id);
|
|
3456
|
+
const stored = storedConfig();
|
|
3457
|
+
if (!stored) throw new PreEffectToolRefusal("the config files do not load; fix them in your editor first: nothing was written");
|
|
3458
|
+
const active = profileName(stored.profile);
|
|
3459
|
+
if (plan.profile !== active) throw new PreEffectToolRefusal(`this card is for the ${plan.profile} profile and the project's profile is ${active}: nothing was written; reject the card, or switch profile first`);
|
|
3460
|
+
// review_checklist replaces as a unit: extend the checklist the profile uses now (the person's, else the shipped one), never start from one line.
|
|
3461
|
+
const current = resolveProfile(stored.profile, stored.profile_overrides).review_checklist ?? [];
|
|
3462
|
+
const next = withChecklistLine(current, plan.line);
|
|
3463
|
+
if ("refused" in next) throw new PreEffectToolRefusal(next.refused);
|
|
3464
|
+
const already = next.list.length === current.length;
|
|
3465
|
+
if (!already) {
|
|
3466
|
+
const written = await settingsSet(LEARN_CHECKLIST.key, next.list, "terminal", undefined, undefined, `ahub learn accept #${plan.id}`, LEARN_CHECKLIST);
|
|
3467
|
+
if (written.ok !== true) throw new PreEffectToolRefusal(String(written.error ?? "the checklist could not be written") + "; the candidate stays open");
|
|
3468
|
+
}
|
|
3469
|
+
const closed = await tasks.learnClose(by, a.id, "accepted");
|
|
3470
|
+
return JSON.stringify({ card: tasks.publicView(closed), accepted: { profile: plan.profile, class: plan.class, criterion: plan.criterion, line: plan.line, tasks: plan.tasks, lines: next.list.length, alreadyPresent: already, file: ".agenthub/config.local.json", applies: "after a restart (profile contracts are bound at startup)", undo: already ? undefined : "ahub settings undo" } });
|
|
3471
|
+
}
|
|
3435
3472
|
case "review_brief": {
|
|
3436
3473
|
const task = board.get(Number(a.id));
|
|
3437
3474
|
return task ? tasks.briefText(task) || `task #${task.id} names no commit, or is private: no brief` : `no task #${a.id}`;
|
|
@@ -6475,13 +6512,17 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6475
6512
|
if (typeof reason !== "string" || !reason.trim() || Buffer.byteLength(reason) > 400) throw new Error("setting reason must contain 1..400 UTF-8 bytes");
|
|
6476
6513
|
return sanitize(reason.trim());
|
|
6477
6514
|
};
|
|
6478
|
-
|
|
6515
|
+
/** `managed` (#514b) is the learn command's own definition: a function argument, never read from a request, so no other key or caller can reach the managed row. */
|
|
6516
|
+
async function settingsSet(key: unknown, raw: unknown, authority: SettingAuthority, confirm?: unknown, now?: unknown, rawReason?: unknown, managed?: SettingDef): Promise<Record<string, unknown>> {
|
|
6479
6517
|
checkOperational(authority);
|
|
6480
6518
|
if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding settings" };
|
|
6481
6519
|
let reason: string | undefined;
|
|
6482
6520
|
try { reason = settingReason(rawReason); } catch (error) { return { ok: false, error: (error as Error).message }; }
|
|
6483
6521
|
if(authority==="conductor" && reason && !tasks.publicMetadataText(reason))return {ok:false,error:"setting reason withheld by privacy policy"};
|
|
6484
|
-
|
|
6522
|
+
if (managed && (managed !== LEARN_CHECKLIST || authority !== "terminal")) return { ok: false, error: "a managed setting is written only by its own command, as the person" };
|
|
6523
|
+
let input: ReturnType<typeof settingInput>;
|
|
6524
|
+
if (managed) { try { input = { def: managed, value: checkSettingValue(managed, raw) }; } catch (error) { return { ok: false, error: (error as Error).message }; } }
|
|
6525
|
+
else input = settingInput(key, raw);
|
|
6485
6526
|
if ("error" in input) return { ok: false, error: input.error };
|
|
6486
6527
|
const { def, value } = input;
|
|
6487
6528
|
const refusal = settingRefusal(def, value, authority);
|
|
@@ -6520,11 +6561,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6520
6561
|
const result = await reloadConfig(authority, def.path.join("."));
|
|
6521
6562
|
if (result.ok !== true) return { ...result, saved: true };
|
|
6522
6563
|
const to = settingScalar(valueAt(storedConfig(), def.path));
|
|
6523
|
-
|
|
6564
|
+
// A managed checklist is the person's own profile text: the event and the notice carry line counts, never the lines.
|
|
6565
|
+
const shown = (v: SettingValue): SettingValue => managed ? (Array.isArray(v) ? `${v.length} line(s)` : v === null ? "(not set)" : v) : v;
|
|
6566
|
+
recordSetting(def.key, shown(from), shown(to), source, false, reason);
|
|
6524
6567
|
if (def.type === "roles") { prepareRoleSelection(def.path[1]!); event({ type: "role", peer: def.path[1]!, actor: "user", stage: "assigned", roles: selectedRoles(def.path[1]!, config.roles, roleAssignments, catalog).map(roleIdentity) }); }
|
|
6525
6568
|
const timing = def.applies === "live" ? "in force now" : def.applies === "restart" ? "pending restart"
|
|
6526
6569
|
: ["permission_modes", "catalog_roles", "peers"].includes(def.path[0]!) ? "applies at the peer's next start" : def.key === "coordination" ? "applies to new tasks" : "applies at next use";
|
|
6527
|
-
return { ok: true, text: `${def.key}: ${settingText(to)} (${timing})` };
|
|
6570
|
+
return { ok: true, text: `${def.key}: ${settingText(shown(to))} (${timing})` };
|
|
6528
6571
|
} catch (error) {
|
|
6529
6572
|
return { ok: false, error: sanitize((error as Error).message), ...(error instanceof OperationalAuthorityError ? { authorityRefused: true, authorityScope: error.scope } : {}), ...(saved ? { saved: true } : {}) };
|
|
6530
6573
|
}
|
|
@@ -6541,8 +6584,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6541
6584
|
if (typeof expectedKey !== "string" || !expectedKey || typeof expectedDigest !== "string" || !/^[a-f0-9]{64}$/.test(expectedDigest)) return { ok: false, error: "invalid undo preview identity; preview again; nothing was undone" };
|
|
6542
6585
|
if (!identity || identity.key !== expectedKey || identity.undoDigest !== expectedDigest) return { ok: false, error: "undo record changed since preview; preview again; nothing was undone" };
|
|
6543
6586
|
}
|
|
6544
|
-
const def =
|
|
6587
|
+
const def = undoableDef(identity?.key);
|
|
6545
6588
|
if (typeof def === "string" || !identity) return { ok: false, error: "nothing to undo" };
|
|
6589
|
+
if (def.managed && authority !== "terminal") return { ok: false, error: `${def.key} was written by ahub learn accept: put it back with ahub settings undo in a terminal` };
|
|
6546
6590
|
if (def.group === "Roster") return changeRosterSetting(def, null, settingSource(authority), confirm, now === true, true, authority, reason, identity);
|
|
6547
6591
|
if (rosterChanging) return { ok: false, error: `roster change for ${rosterChanging} is in progress` };
|
|
6548
6592
|
const read = (): SettingValue => def.store === "routing" ? routingValue(activeRouting(), def) : settingScalar(valueAt(storedConfig(), def.path));
|
|
@@ -6553,9 +6597,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6553
6597
|
undoSetting(opts.cwd, opts.stateDir, scratch => configWriteCheck(scratch, authority, def.path.join(".")), (_routing, overlay) => routingWriteCheck(authority, overlay, def.key), identity);
|
|
6554
6598
|
const result = await reloadConfig(authority, def.store === "routing" ? def.key : def.path.join("."));
|
|
6555
6599
|
if (result.ok !== true) return { ...result, saved: true };
|
|
6556
|
-
|
|
6600
|
+
// A managed checklist is the person's own profile text: the event, the notice and the reply carry its line count, never the lines (the same form as the write).
|
|
6601
|
+
const shown = (v: SettingValue): SettingValue => def.managed ? (Array.isArray(v) ? `${v.length} line(s)` : v === null ? "(not set)" : v) : v;
|
|
6602
|
+
recordSetting(def.key, shown(from), shown(read()), settingSource(authority), true, reason);
|
|
6557
6603
|
if (def.type === "roles") { prepareRoleSelection(def.path[1]!); event({ type: "role", peer: def.path[1]!, actor: "user", stage: "assigned", roles: selectedRoles(def.path[1]!, config.roles, roleAssignments, catalog).map(roleIdentity) }); }
|
|
6558
|
-
return { ok: true, text: `${def.key}: put back to ${settingText(read())}` };
|
|
6604
|
+
return { ok: true, text: `${def.key}: put back to ${settingText(shown(read()))}` };
|
|
6559
6605
|
} catch (error) {
|
|
6560
6606
|
return { ok: false, error: (error as Error).message };
|
|
6561
6607
|
}
|
|
@@ -6594,8 +6640,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6594
6640
|
}
|
|
6595
6641
|
|
|
6596
6642
|
function settingsUndoPreview(): Record<string, unknown> {
|
|
6597
|
-
const identity = pendingUndoIdentity(opts.stateDir), def =
|
|
6643
|
+
const identity = pendingUndoIdentity(opts.stateDir), def = undoableDef(identity?.key);
|
|
6598
6644
|
if (typeof def === "string" || !identity) return { ok: false, error: "nothing to undo" };
|
|
6645
|
+
if (def.managed) return { ok: true, ...identity, lines: [`Undo ${def.key}: put back the previous machine-local file (the review checklist ahub learn accept changed; its lines are not shown here)`, "The saved previous machine-local file is revalidated before writing."] };
|
|
6599
6646
|
const value = restoredValue(opts.cwd, opts.stateDir, def), preview = settingsPreview(def.key, value ?? null);
|
|
6600
6647
|
if (pendingUndoIdentity(opts.stateDir)?.undoDigest !== identity.undoDigest) return { ok: false, error: "undo record changed during preview; preview again" };
|
|
6601
6648
|
return { ...preview, ...identity, lines: [`Undo ${def.key}: put back ${settingText(value)}`, ...(Array.isArray(preview.lines) ? preview.lines : []), "The saved previous machine-local file is revalidated before writing."] };
|
|
@@ -20,8 +20,8 @@ export type JournalDeliveryState =
|
|
|
20
20
|
| "discarded";
|
|
21
21
|
export type JournalResolution = "completed" | "retry" | "discard";
|
|
22
22
|
export const MAX_ENVELOPE_BODY = 1_000_000;
|
|
23
|
+
export const TERMINAL_CAP = 2048;
|
|
23
24
|
const SCHEMA_VERSION = 1,
|
|
24
|
-
TERMINAL_CAP = 2048,
|
|
25
25
|
MAX_ROWS = 100_000,
|
|
26
26
|
MAX_JSON = 20 * 1024 * 1024;
|
|
27
27
|
const STATES = new Set<JournalDeliveryState>([
|
|
@@ -56,6 +56,8 @@ export interface DeliveryJournalOptions {
|
|
|
56
56
|
projectId: string;
|
|
57
57
|
instanceId: string;
|
|
58
58
|
operationId?: string;
|
|
59
|
+
/** Accepted live history target; only terminal mutations apply it, other pruning keeps the legacy cap. */
|
|
60
|
+
terminalHistoryLimit?: () => number;
|
|
59
61
|
}
|
|
60
62
|
export interface JournalSnapshot {
|
|
61
63
|
schemaVersion: number;
|
|
@@ -271,13 +273,16 @@ export class DeliveryJournal {
|
|
|
271
273
|
private closed = false;
|
|
272
274
|
private queueMetadataTracking = false;
|
|
273
275
|
private queueMetadataInvalidated = false;
|
|
276
|
+
private pruningDeferred = 0;
|
|
274
277
|
private readonly operationId?: string;
|
|
278
|
+
private readonly terminalHistoryLimit?: () => number;
|
|
275
279
|
constructor(options: DeliveryJournalOptions) {
|
|
276
280
|
this.file = options.file;
|
|
277
281
|
this.projectRoot = text(options.projectRoot, "projectRoot", 4096);
|
|
278
282
|
this.projectId = text(options.projectId, "projectId", 256);
|
|
279
283
|
this.instanceId = text(options.instanceId, "instanceId", 256);
|
|
280
284
|
this.operationId = options.operationId;
|
|
285
|
+
this.terminalHistoryLimit = options.terminalHistoryLimit;
|
|
281
286
|
mkdirSync(dirname(options.file), { recursive: true, mode: 0o700 });
|
|
282
287
|
this.db = new Database(options.file, { create: true });
|
|
283
288
|
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
@@ -704,53 +709,56 @@ export class DeliveryJournal {
|
|
|
704
709
|
updatedAt: delivery.createdAt,
|
|
705
710
|
});
|
|
706
711
|
return this.queueMetadataTransaction(trackQueuePressure, () => {
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
if ((row.
|
|
712
|
+
// Grouped parents are not linked until the handoff INSERT.
|
|
713
|
+
return this.withPruningDeferred(() => {
|
|
714
|
+
const evidence = this.retryEvidence(delivery.peer, delivery.originals);
|
|
715
|
+
if (evidence === "used") {
|
|
716
|
+
// Inheritance commits with the handoff, before safe failure or pruning can erase a grouped parent.
|
|
717
|
+
this.recordRetryEvidence(delivery.peer, delivery.originals, delivery.id, this.retryWarning(delivery.peer, delivery.originals));
|
|
718
|
+
delivery = { ...delivery, attempt: Math.max(1, delivery.attempt ?? 0) };
|
|
719
|
+
} else if (evidence === "unknown") this.markRetryUnknown([{ ...delivery, revision: 0, updatedAt: delivery.createdAt }], {});
|
|
720
|
+
const ids = new Set(delivery.originals.map((env) => env.id)), immutableIds = new Set(retryOriginals(delivery.originals).map(env => env.id));
|
|
721
|
+
for (const row of this.list(delivery.peer)) {
|
|
722
|
+
if (row.state !== "queued" || !row.originals.some((env) => ids.has(env.id))) continue;
|
|
723
|
+
this.transition(row.id, "discarded", `grouped into delivery ${delivery.id}`);
|
|
724
|
+
if (retryOriginals(row.originals).some(env => immutableIds.has(env.id))) {
|
|
725
|
+
delivery = { ...delivery, previousId: delivery.previousId ?? row.id };
|
|
726
|
+
if ((row.attempt ?? 0) > 0) delivery = { ...delivery, attempt: Math.max(delivery.attempt ?? 0, row.attempt!) };
|
|
727
|
+
}
|
|
720
728
|
}
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
);
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
);
|
|
752
|
-
|
|
753
|
-
|
|
729
|
+
const revision = this.bump();
|
|
730
|
+
const now = Date.now();
|
|
731
|
+
this.db
|
|
732
|
+
.query(
|
|
733
|
+
"UPDATE delivery_meta SET bus_snapshot = ?, manual_paused = ? WHERE project_id = ?",
|
|
734
|
+
)
|
|
735
|
+
.run(
|
|
736
|
+
JSON.stringify(bus),
|
|
737
|
+
JSON.stringify(bus.manualPaused ?? []),
|
|
738
|
+
this.projectId,
|
|
739
|
+
);
|
|
740
|
+
this.writeQueueMetadata(bus, revision, trackQueuePressure);
|
|
741
|
+
this.db
|
|
742
|
+
.query(
|
|
743
|
+
"INSERT INTO deliveries (id, project_id, peer, state, revision, created_at, updated_at, originals, out_json, reason, attempt, previous_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
744
|
+
)
|
|
745
|
+
.run(
|
|
746
|
+
delivery.id,
|
|
747
|
+
this.projectId,
|
|
748
|
+
delivery.peer,
|
|
749
|
+
delivery.state,
|
|
750
|
+
revision,
|
|
751
|
+
delivery.createdAt,
|
|
752
|
+
now,
|
|
753
|
+
JSON.stringify(delivery.originals),
|
|
754
|
+
JSON.stringify(delivery.out),
|
|
755
|
+
delivery.reason ?? null,
|
|
756
|
+
delivery.attempt ?? null,
|
|
757
|
+
delivery.previousId ?? null,
|
|
758
|
+
);
|
|
759
|
+
this.harden();
|
|
760
|
+
return { ...delivery, revision, updatedAt: now };
|
|
761
|
+
});
|
|
754
762
|
});
|
|
755
763
|
}
|
|
756
764
|
createDelivery(
|
|
@@ -785,7 +793,7 @@ export class DeliveryJournal {
|
|
|
785
793
|
input.attempt ?? null,
|
|
786
794
|
input.previousId ?? null,
|
|
787
795
|
);
|
|
788
|
-
this.pruneTerminal();
|
|
796
|
+
this.pruneTerminal(input.state, input.id);
|
|
789
797
|
return { ...input, revision, updatedAt };
|
|
790
798
|
})();
|
|
791
799
|
}
|
|
@@ -802,9 +810,14 @@ export class DeliveryJournal {
|
|
|
802
810
|
return this.db.transaction((): JournalDelivery => {
|
|
803
811
|
const row = this.get(deliveryId);
|
|
804
812
|
if (!row || row.peer !== peer || !["dispatching", "accepted"].includes(row.state)) throw new Error("delivery is not active for this peer");
|
|
805
|
-
const completed = this.
|
|
806
|
-
|
|
807
|
-
|
|
813
|
+
const completed = this.withPruningDeferred(() => {
|
|
814
|
+
const result = this.transition(deliveryId, "completed", reason, row.revision);
|
|
815
|
+
const put = this.db.query("INSERT OR IGNORE INTO task_offer_reads (project_id,peer,envelope_id,delivery_id,source,offer_revision) VALUES (?,?,?,?,?,?)");
|
|
816
|
+
for (const env of row.originals) if (env.from === HUB && env.kind === "task" && Number.isSafeInteger(env.refs?.taskOffer) && env.refs!.taskOffer! >= 1) put.run(this.projectId, peer, env.id, deliveryId, source, env.refs!.taskOffer!);
|
|
817
|
+
return result;
|
|
818
|
+
});
|
|
819
|
+
// Proof is now durable in this transaction, and the return receipt is captured.
|
|
820
|
+
this.pruneTerminal("completed", "");
|
|
808
821
|
return completed;
|
|
809
822
|
})();
|
|
810
823
|
}
|
|
@@ -834,7 +847,7 @@ export class DeliveryJournal {
|
|
|
834
847
|
"UPDATE deliveries SET state = ?, revision = ?, updated_at = ?, reason = ? WHERE id = ? AND project_id = ?",
|
|
835
848
|
)
|
|
836
849
|
.run(state, revision, Date.now(), reason ?? null, id, this.projectId);
|
|
837
|
-
this.pruneTerminal();
|
|
850
|
+
this.pruneTerminal(state, id);
|
|
838
851
|
return this.get(id)!;
|
|
839
852
|
})();
|
|
840
853
|
}
|
|
@@ -1023,7 +1036,7 @@ export class DeliveryJournal {
|
|
|
1023
1036
|
JSON.stringify(retryOriginals(current.originals).map(env => env.id)),
|
|
1024
1037
|
);
|
|
1025
1038
|
if (action === "retry") this.recordRetryEvidence(current.peer, current.originals, id, actor === `peer:${current.peer}`);
|
|
1026
|
-
this.pruneTerminal();
|
|
1039
|
+
this.pruneTerminal(result.state, result.id);
|
|
1027
1040
|
return result;
|
|
1028
1041
|
})();
|
|
1029
1042
|
}
|
|
@@ -1031,7 +1044,15 @@ export class DeliveryJournal {
|
|
|
1031
1044
|
const row = this.db.query("SELECT action,reason,observed_revision,actor FROM resolution_history WHERE project_id=? AND delivery_id=? ORDER BY id DESC LIMIT 1").get(this.projectId, id) as { action: JournalResolution; reason: string; observed_revision: number; actor: string } | null;
|
|
1032
1045
|
return row ? { action: row.action, reason: row.reason, observedRevision: row.observed_revision, actor: row.actor } : undefined;
|
|
1033
1046
|
}
|
|
1034
|
-
private
|
|
1047
|
+
private withPruningDeferred<T>(body: () => T): T {
|
|
1048
|
+
this.pruningDeferred++;
|
|
1049
|
+
try { return body(); } finally { this.pruningDeferred--; }
|
|
1050
|
+
}
|
|
1051
|
+
private pruneTerminal(state: JournalDeliveryState, currentId: string): void {
|
|
1052
|
+
if (this.pruningDeferred) return;
|
|
1053
|
+
const terminal = state === "completed" || state === "discarded" || state === "failed";
|
|
1054
|
+
const terminalCap = terminal ? this.terminalHistoryLimit?.() ?? TERMINAL_CAP : TERMINAL_CAP;
|
|
1055
|
+
// Never delete the receipt this mutation/resolve is still using, at any target.
|
|
1035
1056
|
const count = Number(
|
|
1036
1057
|
(
|
|
1037
1058
|
this.db
|
|
@@ -1041,12 +1062,12 @@ export class DeliveryJournal {
|
|
|
1041
1062
|
.get(this.projectId) as { n: number }
|
|
1042
1063
|
).n,
|
|
1043
1064
|
);
|
|
1044
|
-
if (count <=
|
|
1065
|
+
if (count <= terminalCap) return;
|
|
1045
1066
|
this.db
|
|
1046
1067
|
.query(
|
|
1047
|
-
"DELETE FROM deliveries WHERE id IN (SELECT d.id FROM deliveries d WHERE d.project_id = ? AND d.state IN ('completed','discarded','failed') AND NOT EXISTS (SELECT 1 FROM deliveries c WHERE c.project_id = d.project_id AND c.previous_id = d.id) ORDER BY d.updated_at, d.id LIMIT ?)",
|
|
1068
|
+
"DELETE FROM deliveries WHERE id IN (SELECT d.id FROM deliveries d WHERE d.project_id = ? AND d.state IN ('completed','discarded','failed') AND d.id != ? AND NOT EXISTS (SELECT 1 FROM deliveries c WHERE c.project_id = d.project_id AND c.previous_id = d.id) ORDER BY d.updated_at, d.id LIMIT ?)",
|
|
1048
1069
|
)
|
|
1049
|
-
.run(this.projectId, count -
|
|
1070
|
+
.run(this.projectId, currentId, count - terminalCap);
|
|
1050
1071
|
}
|
|
1051
1072
|
importSnapshot(snapshot: JournalSnapshot, operationId: string, trackQueuePressure = false): boolean {
|
|
1052
1073
|
this.ensureOpen();
|
package/src/hub/learning.ts
CHANGED
|
@@ -78,8 +78,40 @@ export function candidateCard(group: CriterionGroup, windowDays: number, workUni
|
|
|
78
78
|
detail: [
|
|
79
79
|
criterionKey(group),
|
|
80
80
|
`Criterion ${group.criterion} was named unmet ${group.count} time(s) on ${group.tasks.length} ${group.profile}/${group.class} task(s) in the last ${windowDays} days (${workUnits} separate piece(s) of work): ${group.tasks.slice(0, 8).map(id => `#${id}`).join(", ")}.`,
|
|
81
|
-
"This is a candidate for a person to review. Nothing was applied
|
|
81
|
+
"This is a candidate for a person to review. Nothing was applied: the hub writes it only when the person runs ahub learn accept <id> (to .agenthub/config.local.json, this machine only, added to your current checklist; ahub settings undo puts it back), or ahub learn reject <id> to close this card. By hand: profile_overrides is not machine-local: it is read from the shared .agenthub/config.json (tracked, so a change there reaches every checkout) and from .agenthub/config.local.json (this machine only), and the layers merge. Put it where you want it to apply. If you agree, add the line below to profile_overrides.review_checklist; that list replaces as a unit, so add it to your current checklist, do not paste it over it:",
|
|
82
82
|
JSON.stringify(diff, null, 2),
|
|
83
83
|
].join("\n"),
|
|
84
84
|
};
|
|
85
85
|
}
|
|
86
|
+
|
|
87
|
+
/** The cap `resolveProfile` puts on a review checklist (`profiles.ts`): the learn writer never builds a list the loader would refuse. */
|
|
88
|
+
export const CHECKLIST_MAX_LINES = 16;
|
|
89
|
+
const PROFILE = /^[a-z][a-z0-9_-]{0,31}$/, CLASS = /^[a-z][a-z0-9_]{0,47}$/, CRITERION = /^[A-Za-z][A-Za-z0-9-]{0,47}$/;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* What `ahub learn accept` may act on (#514b), read from a card the hub itself filed. ONLY the key line is read: `Criterion candidate key: <profile>:<class>:<criterion>`, as the
|
|
93
|
+
* FIRST line of the detail, on a backlog card the hub filed in the `plan` class. The three parts are re-checked against the shapes the loaders accept, and the line to write is
|
|
94
|
+
* REGENERATED from them by `overridesDiff` with a count the caller recomputes from the board, so no text of the card (the diff it prints, a count it states, a title) is ever
|
|
95
|
+
* parsed as a value or written. A card that does not match is refused with a reason, never half read.
|
|
96
|
+
*/
|
|
97
|
+
export type CandidateCard = { profile: string; class: string; criterion: string } | { refused: string };
|
|
98
|
+
export function parseCandidateCard(task: Pick<Task, "class" | "detail" | "history" | "title">): CandidateCard {
|
|
99
|
+
if (task.class !== "plan" || task.history[0]?.by !== "hub") return { refused: "not a candidate the hub filed" };
|
|
100
|
+
const first = task.detail.split("\n", 1)[0] ?? "";
|
|
101
|
+
const match = /^Criterion candidate key: ([^:\s]+):([^:\s]+):([^:\s]+)$/.exec(first);
|
|
102
|
+
if (!match) return { refused: "not a recurring-criterion candidate (no key line first)" };
|
|
103
|
+
const [, profile, cls, criterion] = match as unknown as [string, string, string, string];
|
|
104
|
+
if (!PROFILE.test(profile) || !CLASS.test(cls) || !CRITERION.test(criterion)) return { refused: "the key line names a profile, class or criterion the loaders would not accept" };
|
|
105
|
+
if (task.title !== `Review checklist candidate for ${profile}/${cls} criterion ${criterion}`) return { refused: "the card's title does not match its key line" };
|
|
106
|
+
return { profile, class: cls, criterion };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The checklist to store after accepting: the person's CURRENT checklist (what the profile uses now) with the one regenerated line added, because `review_checklist` replaces as a
|
|
111
|
+
* unit. The line is skipped when it is already there, so a repeated accept writes nothing new. Refused, not trimmed, when the list would pass `CHECKLIST_MAX_LINES`.
|
|
112
|
+
*/
|
|
113
|
+
export function withChecklistLine(current: readonly string[], line: string): { list: string[] } | { refused: string } {
|
|
114
|
+
if (current.includes(line)) return { list: [...current] };
|
|
115
|
+
if (current.length + 1 > CHECKLIST_MAX_LINES) return { refused: `the checklist already has ${current.length} lines and the profile allows ${CHECKLIST_MAX_LINES}; remove one in .agenthub/config.local.json first` };
|
|
116
|
+
return { list: [...current, line] };
|
|
117
|
+
}
|