@consensus-tools/consensus-tools 0.1.0 → 0.1.3
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/README.md +18 -15
- package/assets/consensus-tools.png +0 -0
- package/bin/consensus-tools.js +34 -0
- package/openclaw.plugin.json +3 -3
- package/package.json +12 -6
- package/src/cli.ts +404 -246
- package/src/cliConfig.ts +97 -0
- package/src/config.ts +8 -5
- package/src/initWizard.ts +237 -0
- package/src/jobs/consensus.ts +116 -1
- package/src/jobs/engine.ts +75 -4
- package/src/standalone.ts +409 -0
- package/src/testing/consensusTestRunner.ts +258 -0
- package/src/types.ts +27 -2
- /package/{LICENSE → LICENSE.txt} +0 -0
package/src/cliConfig.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { existsSync, promises as fs } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export type ConsensusCliConfig = {
|
|
6
|
+
agentId?: string;
|
|
7
|
+
activeBoard: 'local' | 'remote';
|
|
8
|
+
boards: {
|
|
9
|
+
local: { type: 'local'; root: string; jobsPath: string; ledgerPath: string };
|
|
10
|
+
remote: { type: 'remote'; url: string; boardId: string; auth: { type: 'apiKey'; apiKeyEnv: string } };
|
|
11
|
+
};
|
|
12
|
+
defaults: { policy: string; reward: number; stake: number; leaseSeconds: number };
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const defaultConsensusCliConfig: ConsensusCliConfig = {
|
|
16
|
+
activeBoard: 'remote',
|
|
17
|
+
boards: {
|
|
18
|
+
local: {
|
|
19
|
+
type: 'local',
|
|
20
|
+
root: '~/.openclaw/workplace/consensus-board',
|
|
21
|
+
jobsPath: 'jobs',
|
|
22
|
+
ledgerPath: 'ledger.json'
|
|
23
|
+
},
|
|
24
|
+
remote: {
|
|
25
|
+
type: 'remote',
|
|
26
|
+
url: 'https://api.consensus.tools',
|
|
27
|
+
boardId: 'board_all',
|
|
28
|
+
auth: { type: 'apiKey', apiKeyEnv: 'CONSENSUS_API_KEY' }
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
defaults: {
|
|
32
|
+
policy: 'HIGHEST_CONFIDENCE_SINGLE',
|
|
33
|
+
reward: 8,
|
|
34
|
+
stake: 4,
|
|
35
|
+
leaseSeconds: 180
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function expandHome(input: string): string {
|
|
40
|
+
if (!input.startsWith('~')) return input;
|
|
41
|
+
return path.join(os.homedir(), input.slice(1));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resolveCliConfigPath(cwd: string = process.cwd()): string {
|
|
45
|
+
const envPath = process.env.CONSENSUS_CONFIG;
|
|
46
|
+
if (envPath) return expandHome(envPath);
|
|
47
|
+
|
|
48
|
+
const local = path.join(cwd, '.consensus', 'config.json');
|
|
49
|
+
if (existsSync(local)) return local;
|
|
50
|
+
|
|
51
|
+
return path.join(os.homedir(), '.consensus', 'config.json');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function loadCliConfig(cwd: string = process.cwd()): Promise<ConsensusCliConfig> {
|
|
55
|
+
const filePath = resolveCliConfigPath(cwd);
|
|
56
|
+
try {
|
|
57
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
58
|
+
return JSON.parse(raw) as ConsensusCliConfig;
|
|
59
|
+
} catch {
|
|
60
|
+
return JSON.parse(JSON.stringify(defaultConsensusCliConfig)) as ConsensusCliConfig;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function saveCliConfig(config: ConsensusCliConfig, cwd: string = process.cwd()): Promise<void> {
|
|
65
|
+
const filePath = resolveCliConfigPath(cwd);
|
|
66
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
67
|
+
await fs.writeFile(filePath, JSON.stringify(config, null, 2), 'utf8');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function getConfigValue(config: any, key: string): any {
|
|
71
|
+
return key.split('.').reduce((acc, part) => (acc ? acc[part] : undefined), config);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function setConfigValue(config: any, key: string, value: any): void {
|
|
75
|
+
const parts = key.split('.');
|
|
76
|
+
let cur = config as any;
|
|
77
|
+
for (let i = 0; i < parts.length - 1; i += 1) {
|
|
78
|
+
if (!cur[parts[i]]) cur[parts[i]] = {};
|
|
79
|
+
cur = cur[parts[i]];
|
|
80
|
+
}
|
|
81
|
+
cur[parts[parts.length - 1]] = value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function parseValue(input: string): any {
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(input);
|
|
87
|
+
} catch {
|
|
88
|
+
return input;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function resolveRemoteBaseUrl(remoteUrl: string, boardId: string): string {
|
|
93
|
+
const trimmed = remoteUrl.replace(/\/$/, '');
|
|
94
|
+
if (trimmed.includes('/v1/boards/')) return trimmed;
|
|
95
|
+
return `${trimmed}/v1/boards/${boardId}`;
|
|
96
|
+
}
|
|
97
|
+
|
package/src/config.ts
CHANGED
|
@@ -50,8 +50,9 @@ export const configSchema = {
|
|
|
50
50
|
type: {
|
|
51
51
|
type: 'string',
|
|
52
52
|
enum: [
|
|
53
|
-
'
|
|
53
|
+
'FIRST_SUBMISSION_WINS',
|
|
54
54
|
'HIGHEST_CONFIDENCE_SINGLE',
|
|
55
|
+
'APPROVAL_VOTE',
|
|
55
56
|
'OWNER_PICK',
|
|
56
57
|
'TOP_K_SPLIT',
|
|
57
58
|
'MAJORITY_VOTE',
|
|
@@ -59,7 +60,7 @@ export const configSchema = {
|
|
|
59
60
|
'WEIGHTED_REPUTATION',
|
|
60
61
|
'TRUSTED_ARBITER'
|
|
61
62
|
],
|
|
62
|
-
default: '
|
|
63
|
+
default: 'FIRST_SUBMISSION_WINS'
|
|
63
64
|
},
|
|
64
65
|
trustedArbiterAgentId: { type: 'string', default: '' },
|
|
65
66
|
minConfidence: { type: 'number', default: 0, minimum: 0, maximum: 1 },
|
|
@@ -99,8 +100,9 @@ export const configSchema = {
|
|
|
99
100
|
type: {
|
|
100
101
|
type: 'string',
|
|
101
102
|
enum: [
|
|
102
|
-
'
|
|
103
|
+
'FIRST_SUBMISSION_WINS',
|
|
103
104
|
'HIGHEST_CONFIDENCE_SINGLE',
|
|
105
|
+
'APPROVAL_VOTE',
|
|
104
106
|
'OWNER_PICK',
|
|
105
107
|
'TOP_K_SPLIT',
|
|
106
108
|
'MAJORITY_VOTE',
|
|
@@ -190,12 +192,13 @@ export const defaultConfig: ConsensusToolsConfig = {
|
|
|
190
192
|
maxParticipants: 3,
|
|
191
193
|
minParticipants: 1,
|
|
192
194
|
expiresSeconds: 86400,
|
|
193
|
-
consensusPolicy: { type: '
|
|
195
|
+
consensusPolicy: { type: 'FIRST_SUBMISSION_WINS', trustedArbiterAgentId: '', tieBreak: 'earliest' },
|
|
194
196
|
slashingPolicy: { enabled: false, slashPercent: 0, slashFlat: 0 }
|
|
195
197
|
},
|
|
196
198
|
consensusPolicies: {
|
|
197
|
-
|
|
199
|
+
FIRST_SUBMISSION_WINS: { type: 'FIRST_SUBMISSION_WINS' },
|
|
198
200
|
HIGHEST_CONFIDENCE_SINGLE: { type: 'HIGHEST_CONFIDENCE_SINGLE', minConfidence: 0 },
|
|
201
|
+
APPROVAL_VOTE: { type: 'APPROVAL_VOTE', quorum: 1, minScore: 1, minMargin: 0, tieBreak: 'earliest', approvalVote: { weightMode: 'equal', settlement: 'immediate' } },
|
|
199
202
|
OWNER_PICK: { type: 'OWNER_PICK' },
|
|
200
203
|
TOP_K_SPLIT: { type: 'TOP_K_SPLIT', topK: 2, ordering: 'confidence' },
|
|
201
204
|
MAJORITY_VOTE: { type: 'MAJORITY_VOTE' },
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import { createInterface, type Interface } from 'node:readline/promises';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import type { ConsensusCliConfig } from './cliConfig';
|
|
5
|
+
import { defaultConsensusCliConfig } from './cliConfig';
|
|
6
|
+
|
|
7
|
+
export type InitWizardResult = {
|
|
8
|
+
config: ConsensusCliConfig;
|
|
9
|
+
// Values are already shell-escaped for `export KEY=<value>` lines.
|
|
10
|
+
env?: Record<string, string>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const POLICY_CHOICES = [
|
|
14
|
+
'HIGHEST_CONFIDENCE_SINGLE',
|
|
15
|
+
'APPROVAL_VOTE',
|
|
16
|
+
'TOP_K_SPLIT',
|
|
17
|
+
'OWNER_PICK',
|
|
18
|
+
'FIRST_SUBMISSION_WINS',
|
|
19
|
+
'MAJORITY_VOTE',
|
|
20
|
+
'WEIGHTED_VOTE_SIMPLE',
|
|
21
|
+
'WEIGHTED_REPUTATION',
|
|
22
|
+
'TRUSTED_ARBITER'
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
function defaultAgentId(): string {
|
|
26
|
+
const fromEnv = process.env.CONSENSUS_AGENT_ID;
|
|
27
|
+
if (fromEnv) return fromEnv;
|
|
28
|
+
let user = 'cli';
|
|
29
|
+
try {
|
|
30
|
+
const u = os.userInfo();
|
|
31
|
+
if (u?.username) user = u.username;
|
|
32
|
+
} catch {
|
|
33
|
+
// ignore
|
|
34
|
+
}
|
|
35
|
+
return `${user}@${os.hostname()}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function shellEscape(value: string): string {
|
|
39
|
+
// Conservative quoting for bash/zsh.
|
|
40
|
+
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/`/g, '\\`')}"`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function toNumber(value: string, fallback: number): number {
|
|
44
|
+
const n = Number(value);
|
|
45
|
+
return Number.isFinite(n) ? n : fallback;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function toInt(value: string, fallback: number): number {
|
|
49
|
+
const n = Number.parseInt(value, 10);
|
|
50
|
+
return Number.isFinite(n) ? n : fallback;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function promptLine(rl: Interface, message: string, fallback?: string): Promise<string> {
|
|
54
|
+
const suffix = fallback ? ` [${fallback}]` : '';
|
|
55
|
+
const answer = (await rl.question(`${message}${suffix}: `)).trim();
|
|
56
|
+
return answer || fallback || '';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function promptConfirm(rl: Interface, message: string, fallback: boolean): Promise<boolean> {
|
|
60
|
+
const hint = fallback ? 'Y/n' : 'y/N';
|
|
61
|
+
const answer = (await rl.question(`${message} (${hint}): `)).trim().toLowerCase();
|
|
62
|
+
if (!answer) return fallback;
|
|
63
|
+
if (['y', 'yes'].includes(answer)) return true;
|
|
64
|
+
if (['n', 'no'].includes(answer)) return false;
|
|
65
|
+
return fallback;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function promptSelect<T extends string>(
|
|
69
|
+
rl: Interface,
|
|
70
|
+
message: string,
|
|
71
|
+
choices: Array<{ value: T; label: string }>,
|
|
72
|
+
fallback: T
|
|
73
|
+
): Promise<T> {
|
|
74
|
+
process.stdout.write(`${message}\n`);
|
|
75
|
+
for (let i = 0; i < choices.length; i += 1) {
|
|
76
|
+
const c = choices[i];
|
|
77
|
+
process.stdout.write(` ${i + 1}) ${c.label}\n`);
|
|
78
|
+
}
|
|
79
|
+
const answer = (await rl.question(`Select [${choices.findIndex((c) => c.value === fallback) + 1}]: `)).trim();
|
|
80
|
+
if (!answer) return fallback;
|
|
81
|
+
const idx = Number.parseInt(answer, 10);
|
|
82
|
+
if (Number.isFinite(idx) && idx >= 1 && idx <= choices.length) {
|
|
83
|
+
return choices[idx - 1].value;
|
|
84
|
+
}
|
|
85
|
+
// Allow direct value entry.
|
|
86
|
+
const asValue = answer as T;
|
|
87
|
+
if (choices.some((c) => c.value === asValue)) return asValue;
|
|
88
|
+
return fallback;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function promptPassword(message: string): Promise<string> {
|
|
92
|
+
// Minimal masked input for TTY use. Falls back to visible input if raw mode is unavailable.
|
|
93
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY || typeof process.stdin.setRawMode !== 'function') {
|
|
94
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
95
|
+
try {
|
|
96
|
+
return (await rl.question(`${message} (input will be visible): `)).trim();
|
|
97
|
+
} finally {
|
|
98
|
+
rl.close();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return await new Promise<string>((resolve) => {
|
|
103
|
+
const stdin = process.stdin;
|
|
104
|
+
const stdout = process.stdout;
|
|
105
|
+
let value = '';
|
|
106
|
+
|
|
107
|
+
stdout.write(`${message}: `);
|
|
108
|
+
stdin.setRawMode(true);
|
|
109
|
+
stdin.resume();
|
|
110
|
+
|
|
111
|
+
const cleanup = () => {
|
|
112
|
+
stdin.setRawMode(false);
|
|
113
|
+
stdin.pause();
|
|
114
|
+
stdin.removeListener('data', onData);
|
|
115
|
+
stdout.write('\n');
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const onData = (buf: Buffer) => {
|
|
119
|
+
const s = buf.toString('utf8');
|
|
120
|
+
if (s === '\r' || s === '\n') {
|
|
121
|
+
cleanup();
|
|
122
|
+
resolve(value);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (s === '\u0003') {
|
|
126
|
+
// Ctrl-C
|
|
127
|
+
cleanup();
|
|
128
|
+
process.exit(130);
|
|
129
|
+
}
|
|
130
|
+
if (s === '\u007f') {
|
|
131
|
+
// backspace
|
|
132
|
+
if (value.length > 0) {
|
|
133
|
+
value = value.slice(0, -1);
|
|
134
|
+
stdout.write('\b \b');
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
// Ignore arrow keys / escape sequences.
|
|
139
|
+
if (s.startsWith('\u001b')) return;
|
|
140
|
+
value += s;
|
|
141
|
+
stdout.write('*');
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
stdin.on('data', onData);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function runInitWizard(rootDir: string): Promise<InitWizardResult> {
|
|
149
|
+
process.stdout.write(
|
|
150
|
+
[
|
|
151
|
+
'+---------------------------------+',
|
|
152
|
+
'| consensus-tools init wizard |',
|
|
153
|
+
'+---------------------------------+',
|
|
154
|
+
`workspace: ${rootDir}`,
|
|
155
|
+
''
|
|
156
|
+
].join('\n') + '\n'
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
160
|
+
try {
|
|
161
|
+
const mode = await promptSelect<'remote' | 'local'>(
|
|
162
|
+
rl,
|
|
163
|
+
'Where should consensus-tools run?',
|
|
164
|
+
[
|
|
165
|
+
{ value: 'remote', label: 'Hosted board (Recommended)' },
|
|
166
|
+
{ value: 'local', label: 'Local files (shell scripts; limited)' }
|
|
167
|
+
],
|
|
168
|
+
'remote'
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const cfg: ConsensusCliConfig = JSON.parse(JSON.stringify(defaultConsensusCliConfig));
|
|
172
|
+
cfg.activeBoard = mode;
|
|
173
|
+
|
|
174
|
+
cfg.agentId = await promptLine(rl, 'Default agent id (sent as agentId)', cfg.agentId || defaultAgentId());
|
|
175
|
+
|
|
176
|
+
cfg.defaults.policy = await promptSelect<string>(
|
|
177
|
+
rl,
|
|
178
|
+
'Default consensus policy',
|
|
179
|
+
POLICY_CHOICES.map((p) => ({ value: p, label: p })),
|
|
180
|
+
cfg.defaults.policy
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
cfg.defaults.reward = toNumber(await promptLine(rl, 'Default reward (credits)', String(cfg.defaults.reward)), cfg.defaults.reward);
|
|
184
|
+
cfg.defaults.stake = toNumber(await promptLine(rl, 'Default stake (credits)', String(cfg.defaults.stake)), cfg.defaults.stake);
|
|
185
|
+
cfg.defaults.leaseSeconds = toInt(
|
|
186
|
+
await promptLine(rl, 'Default leaseSeconds', String(cfg.defaults.leaseSeconds)),
|
|
187
|
+
cfg.defaults.leaseSeconds
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
if (mode === 'remote') {
|
|
191
|
+
cfg.boards.remote.url = await promptLine(rl, 'Hosted board URL (no trailing /v1/boards)', cfg.boards.remote.url);
|
|
192
|
+
cfg.boards.remote.boardId = await promptLine(rl, 'Board id', cfg.boards.remote.boardId);
|
|
193
|
+
cfg.boards.remote.auth.apiKeyEnv = await promptLine(
|
|
194
|
+
rl,
|
|
195
|
+
'Env var name for access token',
|
|
196
|
+
cfg.boards.remote.auth.apiKeyEnv
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const writeEnv = await promptConfirm(rl, 'Write .consensus/.env now? (do not commit it)', false);
|
|
200
|
+
if (!writeEnv) return { config: cfg };
|
|
201
|
+
|
|
202
|
+
const token = (await promptPassword(`Access token value (${cfg.boards.remote.auth.apiKeyEnv})`)).trim();
|
|
203
|
+
const env = buildEnv(cfg, token);
|
|
204
|
+
return { config: cfg, env };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
cfg.boards.local.root = await promptLine(rl, 'Local board root (used by generated shell scripts)', cfg.boards.local.root);
|
|
208
|
+
return { config: cfg };
|
|
209
|
+
} finally {
|
|
210
|
+
rl.close();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function buildEnv(cfg: ConsensusCliConfig, token: string): Record<string, string> {
|
|
215
|
+
const env: Record<string, string> = {};
|
|
216
|
+
env.CONSENSUS_MODE = cfg.activeBoard === 'remote' ? 'remote' : 'local';
|
|
217
|
+
env.CONSENSUS_AGENT_ID = cfg.agentId || defaultAgentId();
|
|
218
|
+
|
|
219
|
+
env.CONSENSUS_DEFAULT_POLICY = cfg.defaults.policy;
|
|
220
|
+
env.CONSENSUS_DEFAULT_REWARD = String(cfg.defaults.reward);
|
|
221
|
+
env.CONSENSUS_DEFAULT_STAKE = String(cfg.defaults.stake);
|
|
222
|
+
env.CONSENSUS_DEFAULT_LEASE_SECONDS = String(cfg.defaults.leaseSeconds);
|
|
223
|
+
|
|
224
|
+
if (cfg.activeBoard === 'remote') {
|
|
225
|
+
env.CONSENSUS_URL = cfg.boards.remote.url;
|
|
226
|
+
env.CONSENSUS_BOARD_ID = cfg.boards.remote.boardId;
|
|
227
|
+
env.CONSENSUS_API_KEY_ENV = cfg.boards.remote.auth.apiKeyEnv || 'CONSENSUS_API_KEY';
|
|
228
|
+
env[env.CONSENSUS_API_KEY_ENV] = token;
|
|
229
|
+
} else {
|
|
230
|
+
env.CONSENSUS_ROOT = cfg.boards.local.root;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Return a map with already-escaped values for direct file emission.
|
|
234
|
+
const escaped: Record<string, string> = {};
|
|
235
|
+
for (const [k, v] of Object.entries(env)) escaped[k] = shellEscape(v);
|
|
236
|
+
return escaped;
|
|
237
|
+
}
|
package/src/jobs/consensus.ts
CHANGED
|
@@ -56,7 +56,7 @@ export function resolveConsensus(input: ConsensusInput): ConsensusResult {
|
|
|
56
56
|
return { winners: [], winningSubmissionIds: [], consensusTrace: { policy, reason: 'no_submissions' }, finalArtifact: null };
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
if (policy === '
|
|
59
|
+
if (policy === 'FIRST_SUBMISSION_WINS') {
|
|
60
60
|
const sorted = [...input.submissions].sort((a, b) => Date.parse(a.submittedAt) - Date.parse(b.submittedAt));
|
|
61
61
|
const winner = sorted[0];
|
|
62
62
|
return {
|
|
@@ -67,6 +67,121 @@ export function resolveConsensus(input: ConsensusInput): ConsensusResult {
|
|
|
67
67
|
};
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
if (policy === 'APPROVAL_VOTE') {
|
|
71
|
+
const quorum = input.job.consensusPolicy.quorum;
|
|
72
|
+
const minScore = input.job.consensusPolicy.minScore ?? 1;
|
|
73
|
+
const minMargin = input.job.consensusPolicy.minMargin ?? 0;
|
|
74
|
+
const tieBreak = input.job.consensusPolicy.tieBreak ?? 'earliest';
|
|
75
|
+
|
|
76
|
+
const weightMode = input.job.consensusPolicy.approvalVote?.weightMode ?? 'equal';
|
|
77
|
+
const settlement = input.job.consensusPolicy.approvalVote?.settlement ?? 'immediate';
|
|
78
|
+
|
|
79
|
+
// Oracle settlement can always be manually finalized by the arbiter, even with no votes.
|
|
80
|
+
if (settlement === 'oracle' && input.manualWinnerAgentIds && input.manualWinnerAgentIds.length) {
|
|
81
|
+
return {
|
|
82
|
+
winners: input.manualWinnerAgentIds,
|
|
83
|
+
winningSubmissionIds: input.manualSubmissionId ? [input.manualSubmissionId] : [],
|
|
84
|
+
consensusTrace: { policy, settlement, mode: 'manual' },
|
|
85
|
+
finalArtifact: findArtifact(input, input.manualSubmissionId)
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const scores: Record<string, number> = {};
|
|
90
|
+
const voteCounts: Record<string, number> = {};
|
|
91
|
+
|
|
92
|
+
// Only consider votes that target submissions.
|
|
93
|
+
const votes = input.votes.filter((v) => v.submissionId || (v.targetType === 'SUBMISSION' && v.targetId));
|
|
94
|
+
if (quorum && votes.length < quorum) {
|
|
95
|
+
return {
|
|
96
|
+
winners: [],
|
|
97
|
+
winningSubmissionIds: [],
|
|
98
|
+
consensusTrace: { policy, settlement, reason: 'quorum_not_met', quorum, votes: votes.length },
|
|
99
|
+
finalArtifact: null
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (const vote of votes) {
|
|
104
|
+
const sid = vote.submissionId ?? (vote.targetType === 'SUBMISSION' ? vote.targetId : undefined);
|
|
105
|
+
if (!sid) continue;
|
|
106
|
+
|
|
107
|
+
let weight = 1;
|
|
108
|
+
if (weightMode === 'explicit') weight = vote.weight ?? 1;
|
|
109
|
+
if (weightMode === 'reputation') weight = input.reputation(vote.agentId);
|
|
110
|
+
|
|
111
|
+
// score should be +1 (YES) or -1 (NO); clamp to [-1,1] to avoid weirdness.
|
|
112
|
+
const s = Math.max(-1, Math.min(1, vote.score ?? 0));
|
|
113
|
+
scores[sid] = (scores[sid] || 0) + s * weight;
|
|
114
|
+
voteCounts[sid] = (voteCounts[sid] || 0) + 1;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// rank submissions by score desc
|
|
118
|
+
const ranked = input.submissions
|
|
119
|
+
.map((sub) => ({ sub, score: scores[sub.id] || 0, votes: voteCounts[sub.id] || 0 }))
|
|
120
|
+
.sort((a, b) => {
|
|
121
|
+
if (b.score === a.score) {
|
|
122
|
+
if (tieBreak === 'confidence') return b.sub.confidence - a.sub.confidence;
|
|
123
|
+
// default earliest
|
|
124
|
+
return Date.parse(a.sub.submittedAt) - Date.parse(b.sub.submittedAt);
|
|
125
|
+
}
|
|
126
|
+
return b.score - a.score;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const best = ranked[0];
|
|
130
|
+
const second = ranked[1];
|
|
131
|
+
const margin = second ? best.score - second.score : best.score;
|
|
132
|
+
|
|
133
|
+
if (!best || best.votes === 0) {
|
|
134
|
+
return {
|
|
135
|
+
winners: [],
|
|
136
|
+
winningSubmissionIds: [],
|
|
137
|
+
consensusTrace: { policy, settlement, reason: 'no_votes', scores, voteCounts },
|
|
138
|
+
finalArtifact: null
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (best.score < minScore || margin < minMargin) {
|
|
143
|
+
return {
|
|
144
|
+
winners: [],
|
|
145
|
+
winningSubmissionIds: [],
|
|
146
|
+
consensusTrace: { policy, settlement, reason: 'threshold_not_met', minScore, minMargin, best: best.score, margin, scores, voteCounts },
|
|
147
|
+
finalArtifact: null
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (settlement === 'oracle' || tieBreak === 'arbiter') {
|
|
152
|
+
// Oracle / arbiter settlement: allow manual finalization, otherwise provide a recommendation.
|
|
153
|
+
if (input.manualWinnerAgentIds && input.manualWinnerAgentIds.length) {
|
|
154
|
+
return {
|
|
155
|
+
winners: input.manualWinnerAgentIds,
|
|
156
|
+
winningSubmissionIds: input.manualSubmissionId ? [input.manualSubmissionId] : [],
|
|
157
|
+
consensusTrace: {
|
|
158
|
+
policy,
|
|
159
|
+
settlement,
|
|
160
|
+
mode: 'manual',
|
|
161
|
+
recommendedSubmissionId: best.sub.id,
|
|
162
|
+
recommendedAgentId: best.sub.agentId,
|
|
163
|
+
scores,
|
|
164
|
+
voteCounts
|
|
165
|
+
},
|
|
166
|
+
finalArtifact: findArtifact(input, input.manualSubmissionId)
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
winners: [],
|
|
171
|
+
winningSubmissionIds: [],
|
|
172
|
+
consensusTrace: { policy, settlement, mode: 'awaiting_oracle', recommendedSubmissionId: best.sub.id, recommendedAgentId: best.sub.agentId, scores, voteCounts },
|
|
173
|
+
finalArtifact: null
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
winners: [best.sub.agentId],
|
|
179
|
+
winningSubmissionIds: [best.sub.id],
|
|
180
|
+
consensusTrace: { policy, settlement, scores, voteCounts, minScore, minMargin, tieBreak },
|
|
181
|
+
finalArtifact: best.sub.artifacts
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
70
185
|
if (policy === 'HIGHEST_CONFIDENCE_SINGLE') {
|
|
71
186
|
const minConfidence = input.job.consensusPolicy.minConfidence ?? 0;
|
|
72
187
|
const sorted = [...input.submissions]
|
package/src/jobs/engine.ts
CHANGED
|
@@ -297,11 +297,13 @@ export class JobEngine {
|
|
|
297
297
|
async vote(agentId: string, jobId: string, input: VoteInput): Promise<Vote> {
|
|
298
298
|
const now = nowIso();
|
|
299
299
|
return (await this.storage.update((state) => {
|
|
300
|
+
// optional: stake on votes (APPROVAL_VOTE staked settlement)
|
|
301
|
+
const stakeAmount = input.stakeAmount ? Math.max(0, Number(input.stakeAmount)) : 0;
|
|
300
302
|
const job = state.jobs.find((j) => j.id === jobId);
|
|
301
303
|
if (!job) throw new Error(`Job not found: ${jobId}`);
|
|
302
304
|
if (
|
|
303
305
|
job.mode === 'SUBMISSION' &&
|
|
304
|
-
(job.consensusPolicy.type === '
|
|
306
|
+
(job.consensusPolicy.type === 'FIRST_SUBMISSION_WINS' ||
|
|
305
307
|
job.consensusPolicy.type === 'HIGHEST_CONFIDENCE_SINGLE' ||
|
|
306
308
|
job.consensusPolicy.type === 'OWNER_PICK' ||
|
|
307
309
|
job.consensusPolicy.type === 'TOP_K_SPLIT' ||
|
|
@@ -309,6 +311,22 @@ export class JobEngine {
|
|
|
309
311
|
) {
|
|
310
312
|
throw new Error('Voting not enabled for this job');
|
|
311
313
|
}
|
|
314
|
+
|
|
315
|
+
// optional vote stake (only meaningful for APPROVAL_VOTE settlement=staked)
|
|
316
|
+
if (stakeAmount > 0) {
|
|
317
|
+
const currentBalance = getBalance(state.ledger, agentId);
|
|
318
|
+
const nextBalance = currentBalance - Math.abs(stakeAmount);
|
|
319
|
+
ensureNonNegative(nextBalance, `${agentId} vote stake for ${jobId}`);
|
|
320
|
+
state.ledger.push({
|
|
321
|
+
id: newId('ledger'),
|
|
322
|
+
at: now,
|
|
323
|
+
type: 'STAKE',
|
|
324
|
+
agentId,
|
|
325
|
+
amount: -Math.abs(stakeAmount),
|
|
326
|
+
jobId,
|
|
327
|
+
reason: 'vote'
|
|
328
|
+
});
|
|
329
|
+
}
|
|
312
330
|
const targetType = input.targetType ?? (input.submissionId ? 'SUBMISSION' : input.choiceKey ? 'CHOICE' : undefined);
|
|
313
331
|
const targetId = input.targetId ?? input.submissionId;
|
|
314
332
|
if (targetType === 'SUBMISSION') {
|
|
@@ -330,7 +348,7 @@ export class JobEngine {
|
|
|
330
348
|
agentId,
|
|
331
349
|
score,
|
|
332
350
|
weight: input.weight ?? score,
|
|
333
|
-
stakeAmount:
|
|
351
|
+
stakeAmount: stakeAmount || undefined,
|
|
334
352
|
rationale: input.rationale,
|
|
335
353
|
createdAt: now
|
|
336
354
|
};
|
|
@@ -359,10 +377,28 @@ export class JobEngine {
|
|
|
359
377
|
if (arbiter && arbiter !== agentId) {
|
|
360
378
|
throw new Error('Only the trusted arbiter can resolve this job');
|
|
361
379
|
}
|
|
380
|
+
if (!input.manualWinners || input.manualWinners.length === 0) {
|
|
381
|
+
throw new Error('Trusted arbiter must provide manual winners to resolve');
|
|
382
|
+
}
|
|
362
383
|
}
|
|
363
384
|
|
|
364
|
-
if (job.consensusPolicy.type === 'OWNER_PICK'
|
|
365
|
-
|
|
385
|
+
if (job.consensusPolicy.type === 'OWNER_PICK') {
|
|
386
|
+
if (job.createdByAgentId !== agentId) {
|
|
387
|
+
throw new Error('Only the job creator can resolve this job');
|
|
388
|
+
}
|
|
389
|
+
if (!input.manualWinners || input.manualWinners.length === 0) {
|
|
390
|
+
throw new Error('Owner must provide manual winners to resolve');
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (job.consensusPolicy.type === 'APPROVAL_VOTE' && job.consensusPolicy.approvalVote?.settlement === 'oracle') {
|
|
395
|
+
const arbiter = job.consensusPolicy.trustedArbiterAgentId;
|
|
396
|
+
if (arbiter && arbiter !== agentId) {
|
|
397
|
+
throw new Error('Only the trusted arbiter can resolve this job');
|
|
398
|
+
}
|
|
399
|
+
if (!input.manualWinners || input.manualWinners.length === 0) {
|
|
400
|
+
throw new Error('Oracle settlement requires manual winners to resolve');
|
|
401
|
+
}
|
|
366
402
|
}
|
|
367
403
|
|
|
368
404
|
const submissions = state.submissions.filter((s) => s.jobId === jobId);
|
|
@@ -398,6 +434,41 @@ export class JobEngine {
|
|
|
398
434
|
}
|
|
399
435
|
}
|
|
400
436
|
|
|
437
|
+
// Vote-stake settlement for APPROVAL_VOTE (best-effort, local-first)
|
|
438
|
+
if (job.consensusPolicy.type === 'APPROVAL_VOTE' && job.consensusPolicy.approvalVote?.settlement === 'staked') {
|
|
439
|
+
const winnerSubmissionId = consensus.winningSubmissionIds?.[0];
|
|
440
|
+
const voteSlashPercent = Math.max(0, Math.min(1, job.consensusPolicy.approvalVote?.voteSlashPercent ?? 0));
|
|
441
|
+
for (const v of votes) {
|
|
442
|
+
const st = v.stakeAmount ?? 0;
|
|
443
|
+
if (!st || st <= 0) continue;
|
|
444
|
+
|
|
445
|
+
// Return stake by default
|
|
446
|
+
state.ledger.push({
|
|
447
|
+
id: newId('ledger'),
|
|
448
|
+
at: now,
|
|
449
|
+
type: 'UNSTAKE',
|
|
450
|
+
agentId: v.agentId,
|
|
451
|
+
amount: st,
|
|
452
|
+
jobId,
|
|
453
|
+
reason: 'vote'
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// Slash if vote is "wrong" relative to winner.
|
|
457
|
+
// Wrong = YES on non-winner OR NO on winner.
|
|
458
|
+
const votedSubmissionId = v.submissionId ?? (v.targetType === 'SUBMISSION' ? v.targetId : undefined);
|
|
459
|
+
const isYes = (v.score ?? 0) > 0;
|
|
460
|
+
const isNo = (v.score ?? 0) < 0;
|
|
461
|
+
const wrong =
|
|
462
|
+
(winnerSubmissionId && votedSubmissionId && votedSubmissionId !== winnerSubmissionId && isYes) ||
|
|
463
|
+
(winnerSubmissionId && votedSubmissionId && votedSubmissionId === winnerSubmissionId && isNo);
|
|
464
|
+
|
|
465
|
+
if (wrong && voteSlashPercent > 0) {
|
|
466
|
+
const slashAmount = Math.min(st, st * voteSlashPercent);
|
|
467
|
+
slashes.push({ agentId: v.agentId, amount: slashAmount, reason: 'vote_wrong' });
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
401
472
|
const submissionAgents = new Set(submissions.map((s) => s.agentId));
|
|
402
473
|
for (const bid of bids) {
|
|
403
474
|
const stakeAmount = bid.stakeAmount;
|