@staix/agent-hub 0.34.1 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/README.md +35 -6
- package/docs/adding-an-ai-tool.md +2 -2
- package/docs/addons/claude-mem.md +4 -4
- package/docs/addons/open-code-review.md +1 -1
- package/docs/agent-notes/README.md +6 -0
- package/docs/agent-notes/adapters.md +9 -5
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/bus.md +1 -1
- package/docs/agent-notes/daemon.md +4 -3
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/models.md +2 -0
- package/docs/agent-notes/tasks.md +1 -1
- package/docs/agent-notes/tests.md +5 -0
- package/docs/design/adapters.md +110 -2
- package/docs/design/agent-hub-architecture.md +63 -1
- package/docs/design/agent-hub.md +6 -0
- package/docs/design/bus.md +18 -0
- package/docs/design/execution-reference.md +146 -0
- package/docs/design/task-board.md +10 -0
- package/docs/dogfood.md +33 -12
- package/docs/events.md +38 -20
- package/docs/memory.md +21 -7
- package/docs/operations.md +539 -24
- package/docs/quickstart.md +12 -4
- package/docs/research.md +71 -13
- package/docs/roles.md +1 -1
- package/docs/sdlc.md +9 -7
- package/docs/security.md +87 -11
- package/docs/smoke.md +14 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +8 -3
- package/docs/verification/2026-11-07-compat-python-startup-1248.md +51 -0
- package/docs/verification/2026-11-07-compat-receipt-harness-1248.md +105 -0
- package/docs/verification/2026-11-07-compat-receipt-producers-1248.md +96 -0
- package/docs/verification/2026-11-07-compat-workflow-1248.md +94 -0
- package/docs/verification/2026-11-07-daemon-startup-1232.md +169 -0
- package/docs/verification/2026-11-07-jcode-receipt-reconstruction-1248.md +118 -0
- package/docs/verification/2026-11-07-kimi-delayed-usage.md +34 -0
- package/docs/verification/2026-11-07-kimi-review-watch-1284.md +115 -0
- package/docs/verification/2026-11-08-agy-1.5.3.md +96 -0
- package/docs/verification/2026-11-08-agy-acp-t0.md +56 -0
- package/docs/verification/2026-11-08-hub-visual-1058.md +161 -0
- package/docs/verification/2026-11-08-jcode-0991-model-setter-1317.md +143 -0
- package/docs/verification/2026-11-08-kimi-252-model-setter-1317.md +137 -0
- package/docs/verification/2026-11-08-kimi-tui-completion-1282.md +45 -0
- package/docs/verification/2026-11-08-kimi-wire-yield-1283.md +31 -0
- package/docs/verification/2026-11-08-live-model-override-1317.md +250 -0
- package/docs/verification/2026-11-08-qwen-0269-acp.md +138 -0
- package/docs/verification/2026-11-08-qwen-0269-model-setter-1317.md +91 -0
- package/docs/verification/2026-11-09-0.35.0.md +129 -0
- package/docs/verification/2026-11-09-headless-records-1320.md +157 -0
- package/docs/verification/claude-permission-transport-native.py +10 -3
- package/docs/verification/claude-shell-wrapper-native.py +8 -8
- package/docs/verified.json +245 -15
- package/package.json +3 -3
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/kimi-hook.js +20397 -0
- package/plugins/agent-hub/kimi-review-policy-worker.js +217 -0
- package/plugins/agent-hub/server.js +2701 -664
- package/scripts/check-docs.mjs +12 -1
- package/src/adapters/acp-model-policy.ts +56 -0
- package/src/adapters/acp.ts +175 -5
- package/src/adapters/agy/entry.ts +8 -5
- package/src/adapters/agy/hooks.ts +65 -15
- package/src/adapters/channel-peer.ts +33 -1
- package/src/adapters/claude-implementer.ts +16 -3
- package/src/adapters/codex-appserver.ts +118 -8
- package/src/adapters/codex-review-isolation.ts +3 -1
- package/src/adapters/compat.ts +11 -6
- package/src/adapters/definitions/builtin-metadata.ts +8 -1
- package/src/adapters/definitions/native-cli-entry-kimi.ts +76 -7
- package/src/adapters/definitions/native-cli-preview-common.ts +2 -2
- package/src/adapters/definitions/native-cli-preview-kimi.ts +9 -2
- package/src/adapters/definitions/reviewer-variants.ts +3 -1
- package/src/adapters/kimi-review-policy-worker.js +2 -0
- package/src/adapters/kimi-review-policy-worker.ts +85 -0
- package/src/adapters/kimi-review-policy.ts +68 -0
- package/src/adapters/kimi-review.ts +41 -41
- package/src/adapters/kimi-tui-facts.ts +153 -0
- package/src/adapters/kimi-wire-usage.ts +33 -8
- package/src/adapters/local-worker.ts +59 -20
- package/src/adapters/model-token.ts +3 -0
- package/src/adapters/pi.ts +27 -5
- package/src/adapters/registry-types.ts +34 -3
- package/src/adapters/registry.ts +1 -0
- package/src/adapters/reviewer-policy-watch.ts +86 -0
- package/src/adapters/reviewer-print.ts +80 -18
- package/src/cli/agents.ts +1 -1
- package/src/cli/facts-hook.ts +4 -2
- package/src/cli/help.ts +3 -2
- package/src/cli/identity.ts +3 -0
- package/src/cli/kimi-facts-hook.ts +11 -0
- package/src/cli/kimi-plugin.ts +157 -0
- package/src/cli/main.ts +48 -9
- package/src/cli/model.ts +52 -0
- package/src/cli/output.ts +23 -1
- package/src/cli/reset.ts +1 -1
- package/src/cli/status-lines.ts +8 -4
- package/src/hub/budget.ts +1 -1
- package/src/hub/bus.ts +40 -2
- package/src/hub/code-facts.ts +10 -3
- package/src/hub/completions.ts +3 -1
- package/src/hub/config-apply.ts +2 -1
- package/src/hub/control-client.ts +1 -1
- package/src/hub/daemon.ts +1050 -106
- package/src/hub/deliverable-files.ts +38 -5
- package/src/hub/dogfood-profile.ts +1 -1
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +28 -18
- package/src/hub/execution-lineage.ts +120 -0
- package/src/hub/execution-reference.ts +36 -0
- package/src/hub/hub-tools.ts +21 -2
- package/src/hub/instances.ts +4 -3
- package/src/hub/kimi-events.ts +46 -0
- package/src/hub/kimi-usage.ts +132 -29
- package/src/hub/lifecycle.ts +31 -15
- package/src/hub/limits.ts +13 -4
- package/src/hub/manager.ts +15 -2
- package/src/hub/memory-policy.ts +2 -1
- package/src/hub/memory-sync-attestation.ts +54 -0
- package/src/hub/memory-sync-state.ts +20 -5
- package/src/hub/model-overrides.ts +115 -0
- package/src/hub/native-hook-launch.ts +12 -3
- package/src/hub/operational-view.ts +2 -2
- package/src/hub/peers.ts +27 -0
- package/src/hub/permission-mode.ts +1 -1
- package/src/hub/person-figures.ts +88 -0
- package/src/hub/report.ts +38 -4
- package/src/hub/research-dataset.ts +158 -0
- package/src/hub/research-memory.ts +68 -0
- package/src/hub/research-quality.ts +118 -0
- package/src/hub/research-record.ts +279 -0
- package/src/hub/research.ts +126 -27
- package/src/hub/restart.ts +14 -0
- package/src/hub/settings.ts +3 -0
- package/src/hub/startup-event-seeds.ts +51 -0
- package/src/hub/startup-readiness.ts +65 -0
- package/src/hub/startup-timing.ts +38 -0
- package/src/hub/tasks.ts +29 -9
- package/src/hub/ui.ts +9 -5
- package/src/hub/visual-assets.ts +50 -0
- package/src/hub/visual.ts +257 -0
- package/src/memory/brief.ts +169 -14
- package/src/memory/capture.ts +2 -1
- package/src/memory/recall.ts +46 -5
- package/src/memory/record-provenance.ts +82 -0
- package/src/memory/records.ts +116 -0
- package/src/models/route/adec.ts +190 -0
- package/src/models/route/config.ts +37 -3
- package/src/models/route/decision-evidence.ts +83 -0
- package/src/models/route/decision.ts +143 -0
- package/src/models/route/runtime.ts +13 -6
- package/src/ui/index.html +134 -6
- package/src/ui/plugin.ts +65 -4
- package/templates/AGENTS.block.md +1 -1
- package/templates/config.json +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- package/templates/profiles/writing.json +1 -1
package/src/hub/kimi-usage.ts
CHANGED
|
@@ -1,30 +1,43 @@
|
|
|
1
1
|
import { KimiWireReader, kimiCountedTokens, type KimiWireReason, type KimiWireRow } from "../adapters/kimi-wire-usage.ts";
|
|
2
|
+
import type { TaskAttribution } from "./attribution.ts";
|
|
2
3
|
import { TokenWindow } from "./token-window.ts";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* The hub's own count of Kimi's consumption (#285 phase 2): the wire-log rows of the session the adapter owns become `usage` and
|
|
6
|
-
* `tokens` events and a rolling 5 h window against `budget.kimi_tokens_5h`. Read at a turn end
|
|
7
|
-
*
|
|
7
|
+
* `tokens` events and a rolling 5 h window against `budget.kimi_tokens_5h`. Read at a turn end, when a `usage_update` arrives and for
|
|
8
|
+
* a bounded backoff after each cue. Each scheduled slice returns to the event loop before the next one. The ports keep the event log,
|
|
9
|
+
* the budget, the clock and delayed scheduling in the daemon.
|
|
8
10
|
*/
|
|
9
11
|
export const KIMI_WINDOW_MS = 5 * 3_600_000;
|
|
10
12
|
/** A turn's rows are written after it starts: a row older than the hub's first sight of the session (less this slack) is history. */
|
|
11
13
|
const FLOOR_SLACK_MS = 1000;
|
|
12
14
|
const PASSES = 16;
|
|
15
|
+
const FOLLOW_UP_DELAYS_MS = [250, 1000, 2000] as const;
|
|
16
|
+
const TURN_INTERVAL_LIMIT = 32;
|
|
13
17
|
/** Failures a retry can cure: the log was not there yet, or was cut mid-write. Anything else says the count no longer matches the log. */
|
|
14
18
|
const TRANSIENT: ReadonlySet<KimiWireReason> = new Set(["no-index", "io-error", "partial-line"]);
|
|
15
19
|
|
|
20
|
+
export type KimiUsageScheduler = (delayMs: number, run: () => void) => () => void;
|
|
21
|
+
export interface KimiTurnStart { id: string; startedAt: number; attribution: TaskAttribution }
|
|
22
|
+
export interface KimiRowAttribution { attribution: TaskAttribution; turn?: string }
|
|
23
|
+
interface KimiTurnInterval extends KimiTurnStart { endedAt?: number }
|
|
24
|
+
|
|
16
25
|
export interface KimiUsagePorts {
|
|
17
26
|
/** Kimi's data home. */
|
|
18
27
|
home: string;
|
|
19
28
|
/** Per-call byte bound of the reader (its default otherwise); a test shrinks it to force several passes. */
|
|
20
29
|
maxBytes?: number;
|
|
30
|
+
/** Numeric I/O observations for deterministic benchmarks; no path, row or native text. */
|
|
31
|
+
observe?(stats: { bytes: number; discovery: number }): void;
|
|
21
32
|
now(): number;
|
|
22
33
|
/** `budget.kimi_tokens_5h`, read live: 0 turns the window off, never the events. */
|
|
23
34
|
ceiling(): number;
|
|
24
35
|
/** The adapter's own session id, undefined when none is bound. */
|
|
25
36
|
session(peer: string): string | undefined;
|
|
26
|
-
/** One
|
|
27
|
-
|
|
37
|
+
/** One delayed drain at a time. Its cancellation leaves no live timer. Production uses an unreferenced timer. */
|
|
38
|
+
schedule?: KimiUsageScheduler;
|
|
39
|
+
/** One usage event and its counted tokens (`counted` is the one rule in `kimiCountedTokens`), with its captured turn attribution. */
|
|
40
|
+
record(peer: string, row: KimiWireRow, counted: number, attributed: KimiRowAttribution): void;
|
|
28
41
|
/** The window as a reading: `used` is not clamped here. */
|
|
29
42
|
report(peer: string, window: { used: number; resetsAt: number; ceiling: number }): void;
|
|
30
43
|
/** The count cannot be trusted now: the peer's `tokens` reading is dropped, so it shows unknown rather than a short number. */
|
|
@@ -33,7 +46,20 @@ export interface KimiUsagePorts {
|
|
|
33
46
|
notice(line: string): void;
|
|
34
47
|
}
|
|
35
48
|
|
|
36
|
-
interface Bound {
|
|
49
|
+
interface Bound {
|
|
50
|
+
session: string;
|
|
51
|
+
reader: KimiWireReader;
|
|
52
|
+
floor: number;
|
|
53
|
+
turns: KimiTurnInterval[];
|
|
54
|
+
followUp: number;
|
|
55
|
+
passes: number;
|
|
56
|
+
backlog: boolean;
|
|
57
|
+
burst: boolean;
|
|
58
|
+
reportPending: boolean;
|
|
59
|
+
pending?: () => void;
|
|
60
|
+
unknown?: KimiWireReason;
|
|
61
|
+
noticed?: KimiWireReason;
|
|
62
|
+
}
|
|
37
63
|
|
|
38
64
|
export class KimiUsage {
|
|
39
65
|
private readonly bound = new Map<string, Bound>();
|
|
@@ -41,6 +67,7 @@ export class KimiUsage {
|
|
|
41
67
|
private readonly seen = new Set<string>();
|
|
42
68
|
/** A log that stopped matching the count leaves the window short of the truth until its rows age out. */
|
|
43
69
|
private readonly taintedUntil = new Map<string, number>();
|
|
70
|
+
private stopped = false;
|
|
44
71
|
|
|
45
72
|
constructor(private readonly ports: KimiUsagePorts) {}
|
|
46
73
|
|
|
@@ -65,42 +92,110 @@ export class KimiUsage {
|
|
|
65
92
|
}
|
|
66
93
|
|
|
67
94
|
/** The peer went offline: its log is no longer read, so an unreadable state of it must not hold the other peers' figure unknown. */
|
|
68
|
-
drop(peer: string): void {
|
|
95
|
+
drop(peer: string): void {
|
|
96
|
+
const held = this.bound.get(peer);
|
|
97
|
+
if (held) this.cancel(held);
|
|
98
|
+
this.bound.delete(peer);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Stop only delayed work. A final synchronous offline read can still use the bound readers. */
|
|
102
|
+
stop(): void {
|
|
103
|
+
this.stopped = true;
|
|
104
|
+
for (const state of this.bound.values()) this.cancel(state);
|
|
105
|
+
}
|
|
69
106
|
|
|
70
107
|
/** A new window: the old counts would pause the peer again at once (after a budget resume). */
|
|
71
108
|
reset(): void { this.window.reset(); this.taintedUntil.clear(); }
|
|
72
109
|
|
|
73
|
-
/** Called when a turn starts. It
|
|
74
|
-
turnStarted(peer: string): void {
|
|
110
|
+
/** Called when a turn starts. It binds the session, fixes the history floor and captures this turn's immutable attribution. */
|
|
111
|
+
turnStarted(peer: string, turn?: KimiTurnStart): void {
|
|
112
|
+
const bound = this.bind(peer);
|
|
113
|
+
if (!bound || !turn || bound.turns.some(interval => interval.id === turn.id)) return;
|
|
114
|
+
bound.turns.push({ ...turn, attribution: { ...turn.attribution } });
|
|
115
|
+
if (bound.turns.length > TURN_INTERVAL_LIMIT) bound.turns.splice(0, bound.turns.length - TURN_INTERVAL_LIMIT);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
turnEnded(peer: string, turn: string, endedAt: number): void {
|
|
119
|
+
const bound = this.bound.get(peer);
|
|
120
|
+
const index = bound?.turns.findIndex(interval => interval.id === turn) ?? -1;
|
|
121
|
+
if (!bound || index < 0) return;
|
|
122
|
+
bound.turns[index] = { ...bound.turns[index]!, endedAt };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private cancel(bound: Bound): void {
|
|
126
|
+
bound.pending?.(); bound.pending = undefined; bound.burst = false; bound.followUp = 0;
|
|
127
|
+
}
|
|
75
128
|
|
|
76
129
|
private bind(peer: string): Bound | undefined {
|
|
77
|
-
const session = this.ports.session(peer);
|
|
78
|
-
if (!session)
|
|
79
|
-
|
|
130
|
+
const session = this.ports.session(peer), held = this.bound.get(peer);
|
|
131
|
+
if (!session) {
|
|
132
|
+
if (held) this.cancel(held);
|
|
133
|
+
this.bound.delete(peer);
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
80
136
|
if (held?.session === session) return held;
|
|
81
|
-
|
|
137
|
+
if (held) this.cancel(held);
|
|
138
|
+
const next: Bound = {
|
|
139
|
+
session,
|
|
140
|
+
reader: new KimiWireReader({ home: this.ports.home, sessionId: session, ...(this.ports.maxBytes ? { maxBytes: this.ports.maxBytes } : {}) }),
|
|
141
|
+
floor: this.ports.now() - FLOOR_SLACK_MS,
|
|
142
|
+
turns: [], followUp: 0, passes: 0, backlog: false, burst: false, reportPending: false,
|
|
143
|
+
};
|
|
82
144
|
this.bound.set(peer, next);
|
|
83
145
|
return next;
|
|
84
146
|
}
|
|
85
147
|
|
|
86
|
-
collect(peer: string): void {
|
|
148
|
+
collect(peer: string, scheduled = false, terminal = false): void {
|
|
87
149
|
const bound = this.bind(peer);
|
|
88
150
|
if (!bound) return;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
151
|
+
try {
|
|
152
|
+
if (bound.passes >= PASSES) bound.passes = 0;
|
|
153
|
+
let added = 0, more = false;
|
|
154
|
+
// Without a scheduler or for the terminal offline read, retain the bounded synchronous drain. Other scheduled work uses one slice per invocation.
|
|
155
|
+
for (let pass = 0, slices = terminal || !this.ports.schedule ? PASSES : 1; pass < slices; pass++) {
|
|
156
|
+
const read = bound.reader.readSlice();
|
|
157
|
+
// A successful slice records and commits every row before a later slice can fail. An event failure leaves the slice uncommitted,
|
|
158
|
+
// so the next drain reads it again and the seen set removes only rows whose events already succeeded.
|
|
159
|
+
if (read.status === "unknown") { bound.backlog = false; bound.passes = 0; this.unknown(peer, bound, read.reason); return; }
|
|
160
|
+
this.ports.observe?.(bound.reader.stats());
|
|
161
|
+
try { added += this.record(peer, bound, read.rows); }
|
|
162
|
+
catch (error) { bound.backlog = false; bound.passes = 0; throw error; }
|
|
163
|
+
read.commit();
|
|
164
|
+
more = read.more;
|
|
165
|
+
bound.backlog = more;
|
|
166
|
+
bound.passes = more ? bound.passes + 1 : 0;
|
|
167
|
+
if (!more) break;
|
|
168
|
+
}
|
|
169
|
+
const recovered = bound.unknown !== undefined;
|
|
170
|
+
bound.unknown = undefined;
|
|
171
|
+
// ponytail: PASSES slices per cue burst. A longer backlog continues in the next burst, and no figure is reported until it is
|
|
172
|
+
// caught up: a window that is still catching up would be short. Upgrade: seek to the end at bind.
|
|
173
|
+
if (!more && (added > 0 || recovered || bound.reportPending)) { this.report(peer); bound.reportPending = false; }
|
|
174
|
+
} finally {
|
|
175
|
+
if (this.bound.get(peer) === bound) this.schedule(peer, bound, scheduled);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private schedule(peer: string, bound: Bound, scheduled: boolean): void {
|
|
180
|
+
const schedule = this.ports.schedule;
|
|
181
|
+
if (!schedule || this.stopped) return;
|
|
182
|
+
const run = () => {
|
|
183
|
+
bound.pending = undefined;
|
|
184
|
+
if (this.bound.get(peer) === bound) try { this.collect(peer, true); } catch { /* scheduled retries stay bounded */ }
|
|
185
|
+
};
|
|
186
|
+
if (bound.backlog && bound.passes < PASSES) {
|
|
187
|
+
if (!bound.pending) bound.pending = schedule(0, run);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (bound.pending) {
|
|
191
|
+
// A fresh cue during the last pending follow-up still gets a complete bounded retry sequence, without adding another timer.
|
|
192
|
+
if (!scheduled && bound.followUp >= FOLLOW_UP_DELAYS_MS.length - 1) { bound.burst = true; bound.followUp = 0; }
|
|
193
|
+
return;
|
|
98
194
|
}
|
|
99
|
-
|
|
100
|
-
bound.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (!more && (added > 0 || recovered)) this.report(peer);
|
|
195
|
+
if (!bound.burst) { bound.burst = true; bound.followUp = 0; }
|
|
196
|
+
if (bound.followUp >= FOLLOW_UP_DELAYS_MS.length) { bound.burst = false; bound.followUp = 0; return; }
|
|
197
|
+
const delay = FOLLOW_UP_DELAYS_MS[bound.followUp++]!;
|
|
198
|
+
bound.pending = schedule(delay, run);
|
|
104
199
|
}
|
|
105
200
|
|
|
106
201
|
private record(peer: string, bound: Bound, rows: KimiWireRow[]): number {
|
|
@@ -108,15 +203,23 @@ export class KimiUsage {
|
|
|
108
203
|
const now = this.ports.now();
|
|
109
204
|
for (const row of rows) {
|
|
110
205
|
if (this.seen.has(row.id) || row.at < bound.floor) continue;
|
|
206
|
+
const counted = kimiCountedTokens(row), attributed = this.attribute(bound, row.at);
|
|
207
|
+
this.ports.record(peer, row, counted, attributed);
|
|
111
208
|
this.seen.add(row.id);
|
|
112
|
-
const counted = kimiCountedTokens(row);
|
|
113
|
-
this.ports.record(peer, row, counted);
|
|
114
209
|
this.window.add(Math.min(row.at, now), counted);
|
|
210
|
+
if (counted > 0) bound.reportPending = true;
|
|
115
211
|
added += counted;
|
|
116
212
|
}
|
|
117
213
|
return added;
|
|
118
214
|
}
|
|
119
215
|
|
|
216
|
+
private attribute(bound: Bound, at: number): KimiRowAttribution {
|
|
217
|
+
const matches = bound.turns.filter(interval => at >= interval.startedAt && (interval.endedAt === undefined || at <= interval.endedAt));
|
|
218
|
+
if (matches.length !== 1) return { attribution: { attribution: "unattributed" } };
|
|
219
|
+
const turn = matches[0]!;
|
|
220
|
+
return { attribution: { ...turn.attribution }, turn: turn.id };
|
|
221
|
+
}
|
|
222
|
+
|
|
120
223
|
private unknown(peer: string, bound: Bound, reason: KimiWireReason): void {
|
|
121
224
|
bound.unknown = reason;
|
|
122
225
|
try { this.ports.forget(peer); } catch { /* the reading ages out on its own */ }
|
package/src/hub/lifecycle.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { waitForStartupReadiness, type StartupOwner } from "./startup-readiness.ts";
|
|
1
2
|
import { resolveProjectRuntime, recordProjectRuntime, confirmProjectRuntime, readProjectRuntimeForCaller, projectRuntimeRefusal, type ProjectRuntimeSelection } from "./project-runtime.ts";
|
|
2
3
|
import { scheduleRuntimeCapturePrune } from "./runtime-capture-prune.ts";
|
|
3
4
|
import { createProjectRuntimeAccess, type ProjectRuntimeAccess } from "./project-runtime-state.ts";
|
|
4
5
|
import { isDatabaseBusyReply, RecoveryDatabaseBusy } from "./recovery-error.ts";
|
|
5
|
-
import { spawn } from "node:child_process";
|
|
6
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
6
7
|
import { randomUUID } from "node:crypto";
|
|
7
8
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
8
9
|
import { join, resolve } from "node:path";
|
|
@@ -176,7 +177,7 @@ export async function runProjectDaemon(project: Project, unattended = false): Pr
|
|
|
176
177
|
}
|
|
177
178
|
}
|
|
178
179
|
|
|
179
|
-
export async function startProject(project: Project, options: { unattended?: boolean; env?: NodeJS.ProcessEnv; liveTest?: boolean; current?: () => void } = {}): Promise<any> {
|
|
180
|
+
export async function startProject(project: Project, options: { unattended?: boolean; env?: NodeJS.ProcessEnv; liveTest?: boolean; current?: () => void; starting?: (pid: number) => void } = {}): Promise<any> {
|
|
180
181
|
options.current?.();
|
|
181
182
|
assertLifecycleAvailable(project.root);
|
|
182
183
|
const registry = new Registry();
|
|
@@ -195,6 +196,7 @@ export async function startProject(project: Project, options: { unattended?: boo
|
|
|
195
196
|
if (before.state !== "stopped" && before.state !== "starting") throw new Error(before.error ?? before.state);
|
|
196
197
|
if (!existsSync(join(project.root, ".agenthub", "config.json"))) throw new Error("project is not initialized; run ahub init there first");
|
|
197
198
|
let launchedRuntime: ProjectRuntimeSelection | undefined;
|
|
199
|
+
let startupChild: ChildProcess | undefined, startupSignature: string | undefined, launchFailed = false;
|
|
198
200
|
if (before.state === "stopped") {
|
|
199
201
|
const launchEnv = { ...(options.env ?? process.env) };
|
|
200
202
|
// Expired operation ids must not make an ordinary startup load a retired snapshot.
|
|
@@ -225,26 +227,40 @@ export async function startProject(project: Project, options: { unattended?: boo
|
|
|
225
227
|
env: { ...launchEnv, AGENTHUB_STATE_DIR: project.stateDir, AGENTHUB_PROJECT_DIR: project.root,
|
|
226
228
|
AGENTHUB_UNATTENDED: options.unattended ? "1" : "0" },
|
|
227
229
|
});
|
|
228
|
-
|
|
230
|
+
startupChild = child;
|
|
231
|
+
child.on("error", () => { launchFailed = true; }); // The bound child is observed by the readiness wait, never stopped on timeout.
|
|
229
232
|
child.unref();
|
|
230
233
|
} finally { closeSync(log); }
|
|
231
234
|
});
|
|
232
235
|
}
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
236
|
+
const startupOwner = (): StartupOwner => {
|
|
237
|
+
if (!startupChild) return undefined; // A competing starter owns this claim; join without claiming its child.
|
|
238
|
+
const claim = registry.get(project.id);
|
|
239
|
+
if (!claim || claim.root !== project.root || claim.stateDir !== project.stateDir) throw new Error("project registration changed; refresh before starting");
|
|
240
|
+
const claimed = claim.instanceId !== null;
|
|
241
|
+
if (claimed && claim.pid !== startupChild.pid) return undefined;
|
|
242
|
+
if (launchFailed || startupChild.exitCode !== null || startupChild.signalCode !== null || startupChild.pid === undefined) return { state: "gone" };
|
|
243
|
+
// A still-unreaped owned child cannot have its PID reused. Capture the full identity once it is observable.
|
|
244
|
+
startupSignature ??= processSignature(startupChild.pid);
|
|
245
|
+
if (!startupSignature || claimed && claim.pidSignature !== startupSignature) return { state: "unknown" };
|
|
246
|
+
const state = processLiveness(startupChild.pid, startupSignature);
|
|
247
|
+
const fresh = registry.get(project.id);
|
|
248
|
+
if (!fresh || fresh.root !== project.root || fresh.stateDir !== project.stateDir || fresh.instanceId !== claim.instanceId || fresh.pid !== claim.pid || fresh.pidSignature !== claim.pidSignature) return undefined;
|
|
249
|
+
if (startupChild.exitCode !== null || startupChild.signalCode !== null || state === "gone") return { state: "gone" };
|
|
250
|
+
return state === "live" ? { state: "live", pid: startupChild.pid } : { state: "unknown" };
|
|
251
|
+
};
|
|
252
|
+
return await waitForStartupReadiness<any>({
|
|
253
|
+
now: () => performance.now(), sleep: ms => Bun.sleep(ms), current: () => { options.current?.(); }, owner: startupOwner,
|
|
254
|
+
inspect: timeoutMs => inspectProject(registry.get(project.id) ?? project, timeoutMs),
|
|
255
|
+
starting: options.starting,
|
|
256
|
+
confirm: status => {
|
|
257
|
+
options.current?.();
|
|
238
258
|
admitRunningRuntime();
|
|
239
|
-
if (launchedRuntime) confirmProjectRuntime(project, launchedRuntime,
|
|
259
|
+
if (launchedRuntime) confirmProjectRuntime(project, launchedRuntime, status, registry, runtimeAccess);
|
|
240
260
|
// Unknown references defer maintenance; cache cleanup never changes a confirmed startup outcome.
|
|
241
261
|
try { scheduleRuntimeCapturePrune(); } catch { /* retain captures on unreadable inventory or cleanup failure */ }
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
if (observed.state === "incompatible" || observed.state === "missing") throw new Error(observed.error ?? observed.state);
|
|
245
|
-
await Bun.sleep(100);
|
|
246
|
-
}
|
|
247
|
-
throw new Error(`hub readiness not confirmed; inspect ${join(project.stateDir, "hub.log")} before retrying`);
|
|
262
|
+
},
|
|
263
|
+
});
|
|
248
264
|
} finally { registry.close(); }
|
|
249
265
|
}
|
|
250
266
|
|
package/src/hub/limits.ts
CHANGED
|
@@ -39,6 +39,15 @@ export class Limiter {
|
|
|
39
39
|
* answers: "Yes." to two different questions is two messages, not a repeat.
|
|
40
40
|
*/
|
|
41
41
|
admit(from: PeerId, to: PeerId[] | undefined, priority: Priority, body: string, parent = "", group?: string): string | undefined {
|
|
42
|
+
return this.decide(true, from, to, priority, body, parent, group);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Same decision without spending buckets, recording repeat keys, or pruning history. */
|
|
46
|
+
peek(from: PeerId, to: PeerId[] | undefined, priority: Priority, body: string, parent = "", group?: string): string | undefined {
|
|
47
|
+
return this.decide(false, from, to, priority, body, parent, group);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private decide(consume: boolean, from: PeerId, to: PeerId[] | undefined, priority: Priority, body: string, parent: string, group?: string): string | undefined {
|
|
42
51
|
const now = this.now();
|
|
43
52
|
const audience = to?.length ? [...new Set(to)].sort() : ["*"];
|
|
44
53
|
const key = `${from}\0${audience.join(",")}\0${parent}\0${body.trim().replace(/\s+/g, " ")}`;
|
|
@@ -48,8 +57,8 @@ export class Limiter {
|
|
|
48
57
|
if (last !== undefined && now - last < this.cfg.repeat_window_s * 1000) {
|
|
49
58
|
return `the same message went to ${to?.length ? audience.join(", ") : "everyone"} ${Math.round((now - last) / 1000)} s ago`;
|
|
50
59
|
}
|
|
51
|
-
for (const key of keys) this.recent.set(key, now);
|
|
52
|
-
if (this.recent.size > 1000) for (const [k, at] of this.recent) if (now - at >= this.cfg.repeat_window_s * 1000) this.recent.delete(k);
|
|
60
|
+
if (consume) for (const key of keys) this.recent.set(key, now);
|
|
61
|
+
if (consume && this.recent.size > 1000) for (const [k, at] of this.recent) if (now - at >= this.cfg.repeat_window_s * 1000) this.recent.delete(k);
|
|
53
62
|
}
|
|
54
63
|
const wanted: [string, number, number][] = []; // key, capacity, refill per ms
|
|
55
64
|
if (this.cfg.sender_per_min > 0) wanted.push([`s\0${from}`, this.cfg.sender_per_min, this.cfg.sender_per_min / 60_000]);
|
|
@@ -68,11 +77,11 @@ export class Limiter {
|
|
|
68
77
|
const short = filled.filter((f) => f.b.tokens < 1);
|
|
69
78
|
if (short.length) {
|
|
70
79
|
const wait = Math.max(...short.map((f) => Math.ceil(Math.round((1 - f.b.tokens) / f.rate) / 1000))); // whole ms first: 1/(1/x) is not always x
|
|
71
|
-
if (this.cfg.repeat_window_s > 0) for (const key of keys) this.recent.delete(key); // refused, so not sent
|
|
80
|
+
if (consume && this.cfg.repeat_window_s > 0) for (const key of keys) this.recent.delete(key); // refused, so not sent
|
|
72
81
|
const what = short.some((f) => f.key.startsWith("i\0") || f.key.startsWith("gi\0")) ? "important messages" : "messages";
|
|
73
82
|
return `rate limited: too many ${what} from ${from}; retry after ${wait} s${what === "important messages" ? ", or send it without [IMPORTANT]" : ""}`;
|
|
74
83
|
}
|
|
75
|
-
for (const f of filled) this.buckets.set(f.key, { tokens: f.b.tokens - 1, at: now });
|
|
84
|
+
if (consume) for (const f of filled) this.buckets.set(f.key, { tokens: f.b.tokens - 1, at: now });
|
|
76
85
|
return undefined;
|
|
77
86
|
}
|
|
78
87
|
}
|
package/src/hub/manager.ts
CHANGED
|
@@ -132,7 +132,8 @@ export function projectConsole(options: { registry: Registry; lifecycle: Lifecyc
|
|
|
132
132
|
// #1057: the worktree shelf's person actions. The refresh is the one that reads GitHub, so it gets the delivery reads' deadline in its own branch below.
|
|
133
133
|
const worktreeAction = ["worktree_override_set", "worktree_override_clear", "worktrees_refresh_prs"].includes(String(input.action));
|
|
134
134
|
// The settings actions (#269) reach the project hub as an ordinary session's: this dashboard opens no settings session.
|
|
135
|
-
if (!["snapshot", "send", "pause", "resume", "permit", "propose", "intake", "assign", "backlog_pull", "backlog_return", "planning", "planning_undo", "planner_pull", "backlog_split", "release_new", "release_change", "setting", "setting_undo", "setting_preview", "setting_reload", "config_edit_preview", "config_edit_apply", "start_peer", "sdlc_queue", "sdlc_release", "result_seen", "results_read_all", "results_unread_last"].includes(String(input.action)) && !worktreeAction) return { ok: false, error: "invalid dashboard action" };
|
|
135
|
+
if (!["snapshot", "send", "pause", "resume", "permit", "propose", "intake", "assign", "backlog_pull", "backlog_return", "planning", "planning_undo", "planner_pull", "backlog_split", "release_new", "release_change", "setting", "setting_undo", "setting_preview", "setting_reload", "model_preview", "model_confirm", "config_edit_preview", "config_edit_apply", "start_peer", "sdlc_queue", "sdlc_release", "result_seen", "results_read_all", "results_unread_last"].includes(String(input.action)) && !worktreeAction) return { ok: false, error: "invalid dashboard action" };
|
|
136
|
+
const visualSelector = input.action === "snapshot" ? visualSnapshotSelector(input) : {};
|
|
136
137
|
client = await ControlClient.connect(project.stateDir, { role: "console", projectId: project.id, projectRoot: project.root, instanceId: input.instanceId });
|
|
137
138
|
const deliveryRead = input.action === "sdlc_queue" || input.action === "sdlc_release";
|
|
138
139
|
let nominalMs = 10_000;
|
|
@@ -143,7 +144,7 @@ export function projectConsole(options: { registry: Registry; lifecycle: Lifecyc
|
|
|
143
144
|
else if (input.action === "worktrees_refresh_prs") nominalMs = 40_000; // up to 25 s of bounded gh reads plus two git passes
|
|
144
145
|
const timeoutMs = options.forwardTimeoutMs?.(String(input.action), nominalMs) ?? nominalMs;
|
|
145
146
|
const result = await client.request(input.action === "snapshot"
|
|
146
|
-
? { t: "ui_snapshot", after: input.after ?? 0, ...(input.reviewRefresh === true && input.reviewTask !== undefined ? { reviewTask: input.reviewTask, reviewRefresh: true } : {}), ...(input.memory !== undefined ? { memory: input.memory } : {}), ...(dashboardView(input.view) ? { view: input.view } : {}) }
|
|
147
|
+
? { t: "ui_snapshot", after: input.after ?? 0, ...visualSelector, ...(input.reviewRefresh === true && input.reviewTask !== undefined ? { reviewTask: input.reviewTask, reviewRefresh: true } : {}), ...(input.memory !== undefined ? { memory: input.memory } : {}), ...(dashboardView(input.view) ? { view: input.view } : {}) }
|
|
147
148
|
: { t: "ui_action", action: input, instanceId: input.instanceId }, timeoutMs);
|
|
148
149
|
// ControlClient resolves its deadline as this exact sentinel; a hub refusal stays a known failure.
|
|
149
150
|
if (input.action === "intake" && result.ok === false && result.error === `no answer from the hub within ${Math.round(timeoutMs / 1000)} s`) {
|
|
@@ -379,3 +380,15 @@ export async function stopManager(options: ManagerOptions = {}): Promise<void> {
|
|
|
379
380
|
}
|
|
380
381
|
throw new Error("manager shutdown is still pending; project hubs were not changed");
|
|
381
382
|
}
|
|
383
|
+
|
|
384
|
+
/** Closed read selectors only; no caller path, policy or arbitrary control fields are forwarded. */
|
|
385
|
+
export function visualSnapshotSelector(input: Record<string, unknown>): Record<string, string | number> {
|
|
386
|
+
const keys = ["visual", "visualTask", "visualAsset"].filter(key => Object.hasOwn(input, key));
|
|
387
|
+
if (!keys.length) return {};
|
|
388
|
+
if (keys.length !== 1) throw new Error("visual selector is ambiguous; select one visual or task and retry");
|
|
389
|
+
const key = keys[0]!, value = input[key];
|
|
390
|
+
const id = "[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}";
|
|
391
|
+
if (key === "visualTask" ? Number.isSafeInteger(value) && Number(value) > 0
|
|
392
|
+
: typeof value === "string" && new RegExp(key === "visualAsset" ? `^${id}:(before|after)$` : `^${id}$`).test(value)) return { [key]: value as string | number };
|
|
393
|
+
throw new Error("visual selector is invalid; select a current visual or task and retry");
|
|
394
|
+
}
|
package/src/hub/memory-policy.ts
CHANGED
|
@@ -65,9 +65,10 @@ export function isTrustedReviewPackage(env: Envelope): boolean {
|
|
|
65
65
|
&& /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(env.refs.reviewPackage);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
/**
|
|
68
|
+
/** Structured review and visual tool bodies never enter observer capture, including refused inputs. */
|
|
69
69
|
export function reviewCaptureExcluded(tool: string, args: string): boolean {
|
|
70
70
|
const name = tool.replace(/^mcp__[^ ]+__/, "");
|
|
71
|
+
if (name === "hub_visual" || name === "hub_visual_show") return true;
|
|
71
72
|
if (["review_comment_put", "review_comment_delete", "review_comment_send", "review_blob"].includes(name)) return true;
|
|
72
73
|
if (name !== "hub_review") return false;
|
|
73
74
|
try {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { types } from "node:util";
|
|
2
|
+
import { createPlacementAttestation, PlacementWriterContextError, type SyncOptions } from "@staix/agent-mem";
|
|
3
|
+
|
|
4
|
+
/** The closed vocabulary of `PlacementWriterContextError.category` and `.causeClass` (agent-mem 0.23.0). Anything else projects as unknown. */
|
|
5
|
+
const CATEGORIES = ["running CLI", "host package", "setup receipt", "declared writer", "active locator", "home floor", "writer renewal", "unknown"] as const;
|
|
6
|
+
const CAUSE_CLASSES = ["missing", "changed", "unavailable"] as const;
|
|
7
|
+
export type WriterRefusalCategory = typeof CATEGORIES[number];
|
|
8
|
+
export type WriterRefusalCause = typeof CAUSE_CLASSES[number];
|
|
9
|
+
/** Public-safe projection of a writer-context refusal: two closed words, never the package's message, a path or a version. */
|
|
10
|
+
export interface WriterRefusal { category: WriterRefusalCategory; causeClass: WriterRefusalCause }
|
|
11
|
+
|
|
12
|
+
/** Reads a refusal without trusting its prototype chain or getters, and without keeping its message. */
|
|
13
|
+
export function writerRefusalOf(error: unknown): WriterRefusal | undefined {
|
|
14
|
+
if (!error || typeof error !== "object" || types.isProxy(error)) return undefined;
|
|
15
|
+
// The SDK throws this concrete class. Exact prototype identity avoids walking an arbitrary prototype chain.
|
|
16
|
+
if (Object.getPrototypeOf(error) !== PlacementWriterContextError.prototype) return undefined;
|
|
17
|
+
const category = Object.getOwnPropertyDescriptor(error, "category")?.value, causeClass = Object.getOwnPropertyDescriptor(error, "causeClass")?.value;
|
|
18
|
+
return {
|
|
19
|
+
category: (CATEGORIES as readonly unknown[]).includes(category) ? category as WriterRefusalCategory : "unknown",
|
|
20
|
+
causeClass: (CAUSE_CLASSES as readonly unknown[]).includes(causeClass) ? causeClass as WriterRefusalCause : "unavailable",
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Legacy quiet classification reads only an ordinary error's own string, never a getter or proxy. */
|
|
25
|
+
export function memorySyncErrorText(error: unknown): string | undefined {
|
|
26
|
+
if (typeof error === "string") return error;
|
|
27
|
+
if (!error || typeof error !== "object" || types.isProxy(error) || !types.isNativeError(error)) return undefined;
|
|
28
|
+
const descriptor = Object.getOwnPropertyDescriptor(error, "message");
|
|
29
|
+
return descriptor && "value" in descriptor && typeof descriptor.value === "string" ? descriptor.value : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The attestation the hub hands one `syncMemory` pass (#1173). It is agent-mem's own `createPlacementAttestation`, bound to this
|
|
34
|
+
* installation and this memory home; the hub supplies no writer facts of its own and never caches a result past the pass.
|
|
35
|
+
* The transport turns any thrown refusal into a generic "unavailable" result, so the wrapper keeps the first typed refusal for the
|
|
36
|
+
* caller to project; the error is rethrown unchanged so the transport still fails closed.
|
|
37
|
+
*/
|
|
38
|
+
export function hostPlacementAttestation(home: string, create: typeof createPlacementAttestation = createPlacementAttestation): {
|
|
39
|
+
attest: NonNullable<SyncOptions["placementAttestation"]>;
|
|
40
|
+
refusal: () => WriterRefusal | undefined;
|
|
41
|
+
} {
|
|
42
|
+
let inner: ReturnType<typeof createPlacementAttestation> | undefined;
|
|
43
|
+
let refused: WriterRefusal | undefined;
|
|
44
|
+
const attest: NonNullable<SyncOptions["placementAttestation"]> = () => {
|
|
45
|
+
try {
|
|
46
|
+
inner ??= create(home);
|
|
47
|
+
return inner();
|
|
48
|
+
} catch (error) {
|
|
49
|
+
refused ??= writerRefusalOf(error);
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
return { attest, refusal: () => refused };
|
|
54
|
+
}
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
|
|
3
3
|
/** Safe, process-local view of the most recent machine memory sync. */
|
|
4
|
-
export type MemorySyncCause = "declined" | "remote" | "binding" | "stopped" | "transport" | "unknown";
|
|
4
|
+
export type MemorySyncCause = "declined" | "remote" | "binding" | "stopped" | "transport" | "writer" | "unknown";
|
|
5
|
+
/** Closed words of a refused writer attestation (agent-mem `PlacementWriterContextError`); never its message, a path or a version. */
|
|
6
|
+
export interface MemorySyncWriterRefusal { writerCategory?: string; writerCauseClass?: string }
|
|
7
|
+
export const WRITER_CATEGORIES: readonly string[] = ["running CLI", "host package", "setup receipt", "declared writer", "active locator", "home floor", "writer renewal", "unknown"];
|
|
8
|
+
export const WRITER_CAUSE_CLASSES: readonly string[] = ["missing", "changed", "unavailable"];
|
|
9
|
+
const closedWriter = (value: unknown, allowed: readonly string[]): string | undefined => typeof value === "string" && allowed.includes(value) ? value : undefined;
|
|
5
10
|
export interface MemorySyncPlacementCounts { withheldCount: number; exportedCount: number }
|
|
6
11
|
export type MemorySyncOutcome =
|
|
7
12
|
| { state: "notrun" }
|
|
8
13
|
| { state: "not-configured"; at: number }
|
|
9
14
|
| { state: "stopped"; at: number }
|
|
10
15
|
| { state: "ok"; at: number; withheldCount?: number; exportedCount?: number }
|
|
11
|
-
| { state: "failed"; at: number; cause: MemorySyncCause; declinedCount?: number; declinedIdsKnown?: false; placementActive?: false };
|
|
16
|
+
| { state: "failed"; at: number; cause: MemorySyncCause; declinedCount?: number; declinedIdsKnown?: false; placementActive?: false } & MemorySyncWriterRefusal;
|
|
12
17
|
|
|
13
18
|
export interface MemorySyncState {
|
|
14
19
|
outcome: MemorySyncOutcome;
|
|
@@ -19,7 +24,7 @@ export interface MemorySyncState {
|
|
|
19
24
|
export type MemorySyncEvent =
|
|
20
25
|
| { type: "success"; at: number; withheldCount?: number; exportedCount?: number }
|
|
21
26
|
| { type: "quiet"; at: number; state: "not-configured" | "stopped" }
|
|
22
|
-
| { type: "failure"; at: number; cause: MemorySyncCause; declinedCount?: number; declinedIdsFingerprint?: string; declinedIdsKnown?: false; failureDetailHash?: string; placementActive?: false };
|
|
27
|
+
| { type: "failure"; at: number; cause: MemorySyncCause; declinedCount?: number; declinedIdsFingerprint?: string; declinedIdsKnown?: false; failureDetailHash?: string; placementActive?: false } & MemorySyncWriterRefusal;
|
|
23
28
|
|
|
24
29
|
export const initialMemorySyncState = (): MemorySyncState => ({ outcome: { state: "notrun" } });
|
|
25
30
|
const MAX_DECLINED_ROWS = 20_000;
|
|
@@ -50,14 +55,20 @@ export function memorySyncPlacementCounts(value: unknown): MemorySyncPlacementCo
|
|
|
50
55
|
export function reduceMemorySync(state: MemorySyncState, event: MemorySyncEvent): { state: MemorySyncState; logFailure: boolean } {
|
|
51
56
|
if (event.type === "success") return { state: { outcome: { state: "ok", at: event.at, ...placementCounts(event.withheldCount, event.exportedCount) } }, logFailure: false };
|
|
52
57
|
if (event.type === "quiet") return { state: { outcome: { state: event.state, at: event.at } }, logFailure: false };
|
|
53
|
-
const signature = [event.cause, event.declinedCount ?? "", event.declinedIdsFingerprint ?? (event.declinedIdsKnown === false ? "ids-unknown" : ""), event.failureDetailHash ?? ""].join("\0");
|
|
58
|
+
const signature = [event.cause, event.declinedCount ?? "", event.declinedIdsFingerprint ?? (event.declinedIdsKnown === false ? "ids-unknown" : ""), event.failureDetailHash ?? "", event.cause === "writer" ? closedWriter(event.writerCategory, WRITER_CATEGORIES) ?? "" : "", event.cause === "writer" ? closedWriter(event.writerCauseClass, WRITER_CAUSE_CLASSES) ?? "" : ""].join("\0");
|
|
54
59
|
const changed = state.failureSignature !== signature;
|
|
55
60
|
return {
|
|
56
|
-
state: { outcome: { state: "failed", at: event.at, cause: event.cause, ...(event.declinedCount === undefined ? {} : { declinedCount: event.declinedCount }), ...(event.declinedIdsKnown === false ? { declinedIdsKnown: false as const } : {}), ...(event.cause === "declined" && event.placementActive === false ? { placementActive: false as const } : {}) }, failureSignature: signature },
|
|
61
|
+
state: { outcome: { state: "failed", at: event.at, cause: event.cause, ...(event.declinedCount === undefined ? {} : { declinedCount: event.declinedCount }), ...(event.declinedIdsKnown === false ? { declinedIdsKnown: false as const } : {}), ...(event.cause === "declined" && event.placementActive === false ? { placementActive: false as const } : {}), ...writerFields(event) }, failureSignature: signature },
|
|
57
62
|
logFailure: changed,
|
|
58
63
|
};
|
|
59
64
|
}
|
|
60
65
|
|
|
66
|
+
function writerFields(value: { cause?: string } & MemorySyncWriterRefusal): MemorySyncWriterRefusal {
|
|
67
|
+
if (value.cause !== "writer") return {};
|
|
68
|
+
const writerCategory = closedWriter(value.writerCategory, WRITER_CATEGORIES), writerCauseClass = closedWriter(value.writerCauseClass, WRITER_CAUSE_CLASSES);
|
|
69
|
+
return { ...(writerCategory ? { writerCategory } : {}), ...(writerCauseClass ? { writerCauseClass } : {}) };
|
|
70
|
+
}
|
|
71
|
+
|
|
61
72
|
export interface MemorySyncView {
|
|
62
73
|
state: "disabled" | "unavailable" | "notrun" | "not-configured" | "stopped" | "ok" | "failed" | "unknown";
|
|
63
74
|
at?: number;
|
|
@@ -67,6 +78,8 @@ export interface MemorySyncView {
|
|
|
67
78
|
withheldCount?: number;
|
|
68
79
|
exportedCount?: number;
|
|
69
80
|
placementActive?: false;
|
|
81
|
+
writerCategory?: string;
|
|
82
|
+
writerCauseClass?: string;
|
|
70
83
|
nextStep?: string;
|
|
71
84
|
}
|
|
72
85
|
|
|
@@ -75,6 +88,7 @@ export function memorySyncNextStep(cause: MemorySyncCause): string {
|
|
|
75
88
|
: cause === "remote" ? "configure a private sync remote"
|
|
76
89
|
: cause === "binding" ? "ahub memory profile-check"
|
|
77
90
|
: cause === "stopped" ? "retry after hub restart"
|
|
91
|
+
: cause === "writer" ? "repair the amem install and its host pin, then a person runs amem migrate --sync-placement --dry-run"
|
|
78
92
|
: "run ahub memory sync as a person";
|
|
79
93
|
}
|
|
80
94
|
|
|
@@ -91,6 +105,7 @@ export function memorySyncView(state: MemorySyncState, availability: "enabled" |
|
|
|
91
105
|
...(outcome.declinedCount === undefined ? {} : { declinedCount: outcome.declinedCount }),
|
|
92
106
|
...(outcome.declinedIdsKnown === false ? { declinedIdsKnown: false } : {}),
|
|
93
107
|
...(outcome.cause === "declined" && outcome.placementActive === false ? { placementActive: false } : {}),
|
|
108
|
+
...writerFields(outcome),
|
|
94
109
|
nextStep: memorySyncNextStep(outcome.cause),
|
|
95
110
|
};
|
|
96
111
|
}
|