pi2web 0.2.1 → 0.2.8
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 +53 -6
- package/dist/activity.js +178 -0
- package/dist/agent-feed.js +133 -0
- package/dist/agent-templates.js +144 -0
- package/dist/agents.js +671 -152
- package/dist/auth.js +3 -1
- package/dist/collab/baseline.js +84 -0
- package/dist/collab/dispatcher.js +45 -10
- package/dist/collab/hub.js +1392 -384
- package/dist/collab/pi-extension.js +322 -65
- package/dist/collab/review-flow.js +197 -48
- package/dist/collab/routes.js +94 -23
- package/dist/collab/schemas.js +69 -13
- package/dist/collab/scoring-flow.js +145 -39
- package/dist/collab/similarity.js +15 -0
- package/dist/collab/store.js +467 -112
- package/dist/collab/types.js +94 -10
- package/dist/collab/validate.js +62 -25
- package/dist/drafts.js +26 -6
- package/dist/http/agent-routes.js +222 -0
- package/dist/http/login-limiter.js +53 -0
- package/dist/http/mail-settings-routes.js +53 -0
- package/dist/http/system-prompt-routes.js +52 -0
- package/dist/http/utils.js +47 -0
- package/dist/http/web-assets.js +64 -0
- package/dist/http/workspace-routes.js +58 -0
- package/dist/mail-notifier.js +63 -17
- package/dist/main.js +61 -16
- package/dist/mcp.js +24 -5
- package/dist/metadata-store.js +203 -56
- package/dist/paths.js +10 -0
- package/dist/sdk-backend.js +249 -45
- package/dist/server.js +488 -614
- package/dist/session-namer.js +66 -17
- package/dist/session-performance.js +137 -0
- package/dist/sotto-stt.js +217 -0
- package/dist/terminals.js +94 -27
- package/dist/timeline.js +185 -0
- package/dist/voice.js +153 -35
- package/dist/workspaces.js +91 -26
- package/dist/ws/agent-socket.js +256 -0
- package/dist/ws/terminal-socket.js +46 -0
- package/npm-shrinkwrap.json +3685 -0
- package/package.json +11 -1
- package/packages/protocol/openapi.yaml +24 -2
- package/packages/protocol/websocket.schema.json +58 -15
- package/web/activity-view.js +121 -0
- package/web/app.js +3647 -756
- package/web/backend-state.js +59 -27
- package/web/collab.js +684 -257
- package/web/hid-keyboard.js +173 -140
- package/web/index.html +7 -2
- package/web/styles.css +21 -2
- package/web/voice-pcm.js +36 -0
package/dist/collab/hub.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import { execFile, execFileSync
|
|
2
|
-
import { createHash } from 'node:crypto';
|
|
3
|
-
import { createReadStream } from 'node:fs';
|
|
4
|
-
import path from 'node:path';
|
|
1
|
+
import { execFile, execFileSync } from 'node:child_process';
|
|
5
2
|
import { promisify } from 'node:util';
|
|
6
3
|
import { COLLAB_ERRORS } from './types.js';
|
|
4
|
+
import { gitBaseline } from './baseline.js';
|
|
5
|
+
import { similarity } from './similarity.js';
|
|
6
|
+
export { gitBaseline };
|
|
7
|
+
const run = promisify(execFile);
|
|
7
8
|
import { ValidationError, parse } from './validate.js';
|
|
8
9
|
import { advanceRequest, createParticipantRequest, createSessionRequest, debateArgumentRequest, debateArgumentsRequest, escalationRequest, findingsRequest, finalizeRequest, nominationsRequest, participantBudgetRequest, policyPatch, readyRequest, rebindParticipantRequest, recheckRequest, resolveEscalationRequest, scoresRequest, votesRequest, issueVotesRequest, mergeVotesRequest, issueDiscussionsRequest, retryWaitingRequest } from './schemas.js';
|
|
9
10
|
import { analyse, assertScoringCapability, assertScoringPhase, contestedCriteria, finalizeScores, isScoringReadyToAdvance, nextScoringPhase, nominationsSealed, scoresSealed, scoringPanel, scoringProgress, scoringWaitingOn, tallyVotes, votesSealed } from './scoring-flow.js';
|
|
10
11
|
import { applyEscalation, applyHumanRuling, applyWithdraw, approvalSummary, assertCanFileFinding, assertCapability, canSeeOthersFindings, flowError, isOpenIssue, isReadyToAdvance, nextPhase, sessionProgress, stallCheck, waitingOn, currentIssueVotes, issueConsensus, activeContestedIssueIds, pendingCrossVoters, owedIssueVoteIds } from './review-flow.js';
|
|
11
|
-
const run = promisify(execFile);
|
|
12
12
|
/**
|
|
13
13
|
* Shared rulebook handed to every seat. A reviewer that does not know the panel size, the vote semantics, or
|
|
14
14
|
* what happens to a contested finding cannot calibrate anything: the first live panels produced one reviewer
|
|
@@ -23,71 +23,17 @@ const SEVERITY_GUIDE = {
|
|
|
23
23
|
nit: 'Style, naming, or wording. Never blocks a merge.'
|
|
24
24
|
};
|
|
25
25
|
const REQUIRED_ACTION_GUIDE = {
|
|
26
|
-
must_fix: 'Must not merge with this unfixed.',
|
|
27
|
-
|
|
26
|
+
must_fix: 'Must not merge with this unfixed.',
|
|
27
|
+
should_fix: 'Fix in this change unless there is a reason not to.',
|
|
28
|
+
discuss: 'A judgment call that needs a decision, not necessarily a code change.',
|
|
29
|
+
fyi: 'Recorded for awareness only.'
|
|
28
30
|
};
|
|
29
31
|
const VOTE_GUIDE = {
|
|
30
32
|
approve: 'The finding is factually correct at this baseline and in scope for this review. Approve even if you would have rated the severity lower; say so in the rationale.',
|
|
31
|
-
reject:
|
|
33
|
+
reject: "Only when the finding is factually wrong, already handled elsewhere in the code, or outside this review's subject. A different severity or a matter of taste is not a reject."
|
|
32
34
|
};
|
|
33
35
|
/** How long a queued task may sit undelivered before the human is told that the wake-up is not landing. */
|
|
34
36
|
const UNDELIVERED_TASK_WARNING_MS = 120_000;
|
|
35
|
-
/** Anchors every round to an immutable code state so round N+1 verdicts are not made against round N's memory. */
|
|
36
|
-
export const gitBaseline = async ({ cwd, subject }) => {
|
|
37
|
-
const git = async (args) => (await run('git', args, { cwd })).stdout;
|
|
38
|
-
try {
|
|
39
|
-
const commit = (await git(['rev-parse', 'HEAD'])).trim();
|
|
40
|
-
let dirtyHash;
|
|
41
|
-
const status = await git(['status', '--porcelain', '-z']);
|
|
42
|
-
if (status) {
|
|
43
|
-
const untracked = (await git(['ls-files', '--others', '--exclude-standard', '-z'])).split('\0').filter(Boolean).sort();
|
|
44
|
-
// Hashed as a stream, never buffered. A working tree diff can be tens of megabytes, and a diff dropped for
|
|
45
|
-
// exceeding a buffer would leave a hash of nothing but file names - identical before and after an edit to
|
|
46
|
-
// those same files, which is exactly what `assertBaselineCurrent()` exists to catch.
|
|
47
|
-
const hash = createHash('sha256').update(status);
|
|
48
|
-
await hashStream(hash, spawn('git', ['diff', 'HEAD', '--binary'], { cwd, stdio: ['ignore', 'pipe', 'ignore'] }));
|
|
49
|
-
for (const relative of untracked) {
|
|
50
|
-
hash.update(relative).update('\0');
|
|
51
|
-
// A file removed during capture is already represented by status.
|
|
52
|
-
await hashStream(hash, createReadStream(path.join(cwd, relative))).catch(() => { });
|
|
53
|
-
}
|
|
54
|
-
dirtyHash = `sha256:${hash.digest('hex')}`;
|
|
55
|
-
}
|
|
56
|
-
// A commit range such as `main...feature` is only a name: either endpoint can move while HEAD and the
|
|
57
|
-
// working tree stay identical, which would silently change the code under review. Resolve both endpoints
|
|
58
|
-
// so the pinned identity covers the range, not just its spelling.
|
|
59
|
-
let rangeResolved;
|
|
60
|
-
if (subject.type === 'commit_range') {
|
|
61
|
-
const expression = subject.value.trim();
|
|
62
|
-
const endpoints = expression.includes('...') ? expression.split('...') : expression.includes('..') ? expression.split('..') : [expression];
|
|
63
|
-
const resolved = [];
|
|
64
|
-
for (const endpoint of endpoints) {
|
|
65
|
-
const name = endpoint.trim();
|
|
66
|
-
resolved.push(name ? (await git(['rev-parse', '--verify', `${name}^{commit}`]).catch(() => '')).trim() || `unresolved:${name}` : 'HEAD');
|
|
67
|
-
}
|
|
68
|
-
rangeResolved = resolved.join('...');
|
|
69
|
-
}
|
|
70
|
-
return { vcs: 'git', commit, range: subject.type === 'commit_range' ? subject.value : undefined, rangeResolved, dirtyHash, paths: subject.type === 'paths' ? subject.value.split(/[\n,]/).map(value => value.trim()).filter(Boolean) : [] };
|
|
71
|
-
}
|
|
72
|
-
catch {
|
|
73
|
-
// Not a git checkout (or git is missing). The hub still works; it just cannot prove code identity.
|
|
74
|
-
return { vcs: 'none', paths: [] };
|
|
75
|
-
}
|
|
76
|
-
};
|
|
77
|
-
/** Feeds one stream into a hash, rejecting on failure so a partial read can never pass as "nothing changed". */
|
|
78
|
-
function hashStream(hash, source) {
|
|
79
|
-
const process = 'stdout' in source ? source : undefined, stream = (process ? process.stdout : source);
|
|
80
|
-
return new Promise((resolve, reject) => {
|
|
81
|
-
if (!stream)
|
|
82
|
-
return reject(new Error('Stream is not readable'));
|
|
83
|
-
stream.on('data', chunk => hash.update(chunk));
|
|
84
|
-
stream.on('error', reject);
|
|
85
|
-
if (!process)
|
|
86
|
-
return void stream.on('end', resolve);
|
|
87
|
-
process.on('error', reject);
|
|
88
|
-
process.on('close', code => code === 0 ? resolve() : reject(new Error(`git exited with ${code}`)));
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
37
|
const fieldError = (path, code, message) => ({ path, code, message });
|
|
92
38
|
/**
|
|
93
39
|
* Orchestration layer: authentication, validation, persistence, event emission, and phase advancement.
|
|
@@ -106,15 +52,30 @@ export class CollabHub {
|
|
|
106
52
|
this.resolveBaseline = options.resolveBaseline ?? gitBaseline;
|
|
107
53
|
this.now = options.now ?? (() => Date.now());
|
|
108
54
|
}
|
|
109
|
-
init() {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
55
|
+
init() {
|
|
56
|
+
this.store.init();
|
|
57
|
+
}
|
|
58
|
+
shutdown() {
|
|
59
|
+
this.closed = true;
|
|
60
|
+
}
|
|
61
|
+
subscribe(listener) {
|
|
62
|
+
this.listeners.add(listener);
|
|
63
|
+
return () => this.listeners.delete(listener);
|
|
64
|
+
}
|
|
65
|
+
emit(event) {
|
|
66
|
+
for (const listener of this.listeners)
|
|
67
|
+
try {
|
|
68
|
+
listener(event);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* A broken subscriber must not roll back committed state. */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
record(sessionId, type, payload = {}, actorId) {
|
|
75
|
+
const event = this.store.appendEvent(sessionId, type, payload, actorId);
|
|
76
|
+
this.emit(event);
|
|
77
|
+
return event;
|
|
78
|
+
}
|
|
118
79
|
/** Queues a durable wake-up. The dispatcher prompts; collab_get_task acknowledges collection. */
|
|
119
80
|
push(sessionId, participantId, type, payload) {
|
|
120
81
|
return this.store.pushInbox(sessionId, participantId, type, payload);
|
|
@@ -134,7 +95,9 @@ export class CollabHub {
|
|
|
134
95
|
try {
|
|
135
96
|
this.record(sessionId, type, payload);
|
|
136
97
|
}
|
|
137
|
-
catch {
|
|
98
|
+
catch {
|
|
99
|
+
/* the session may have been removed while a wake-up was in flight */
|
|
100
|
+
}
|
|
138
101
|
}
|
|
139
102
|
/**
|
|
140
103
|
* What this seat owes *now*. A wake-up is queued when a phase opens but delivered only once the agent's
|
|
@@ -157,26 +120,56 @@ export class CollabHub {
|
|
|
157
120
|
async createSession(body, resolveCwd) {
|
|
158
121
|
const input = parse(createSessionRequest, body);
|
|
159
122
|
const cwd = await resolveCwd(input.workspaceId, input.relativeCwd);
|
|
160
|
-
const session = this.store.createSession({
|
|
161
|
-
|
|
123
|
+
const session = this.store.createSession({
|
|
124
|
+
kind: input.kind,
|
|
125
|
+
title: input.title,
|
|
126
|
+
workspaceId: input.workspaceId,
|
|
127
|
+
cwd,
|
|
128
|
+
subject: input.subject,
|
|
129
|
+
policy: input.policy
|
|
130
|
+
});
|
|
131
|
+
this.record(session.sessionId, 'session_created', {
|
|
132
|
+
kind: session.kind,
|
|
133
|
+
title: session.title,
|
|
134
|
+
cwd,
|
|
135
|
+
subject: session.subject
|
|
136
|
+
});
|
|
162
137
|
// Scoring has no open-round hand-off, so pin its code before the first panel task is issued.
|
|
163
138
|
if (session.kind === 'scoring')
|
|
164
139
|
await this.captureBaseline(session);
|
|
165
140
|
return session;
|
|
166
141
|
}
|
|
167
|
-
listSessions(filter = {}) {
|
|
168
|
-
|
|
142
|
+
listSessions(filter = {}) {
|
|
143
|
+
return this.store.listSessions(filter);
|
|
144
|
+
}
|
|
145
|
+
getSession(sessionId) {
|
|
146
|
+
return this.store.getSession(sessionId);
|
|
147
|
+
}
|
|
169
148
|
updatePolicy(sessionId, body) {
|
|
170
149
|
const patch = parse(policyPatch, body), session = this.store.getSession(sessionId);
|
|
171
|
-
const updated = this.store.updateSession(sessionId, {
|
|
150
|
+
const updated = this.store.updateSession(sessionId, {
|
|
151
|
+
policy: {
|
|
152
|
+
...session.policy,
|
|
153
|
+
...patch,
|
|
154
|
+
scoring: {
|
|
155
|
+
...session.policy.scoring,
|
|
156
|
+
...patch.scoring,
|
|
157
|
+
scale: { ...session.policy.scoring.scale, ...patch.scoring?.scale }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
});
|
|
172
161
|
this.record(sessionId, 'policy_updated', { policy: updated.policy });
|
|
173
162
|
return updated;
|
|
174
163
|
}
|
|
175
164
|
/** Phases in which a new seat can still be given a well-defined task; anywhere else it would only deadlock. */
|
|
176
|
-
registrationPhases(session) {
|
|
165
|
+
registrationPhases(session) {
|
|
166
|
+
return session.kind === 'scoring' ? ['nominating'] : ['draft', 'implementing', 'collecting'];
|
|
167
|
+
}
|
|
177
168
|
/** A dedicated collaboration Agent may own only one live seat globally; agentId alone is the bridge identity. */
|
|
178
169
|
assertAgentAvailable(agentId, _sessionId, exceptParticipantId) {
|
|
179
|
-
const occupied = this.store
|
|
170
|
+
const occupied = this.store
|
|
171
|
+
.findOccupyingParticipantsByAgent(agentId)
|
|
172
|
+
.find(participant => participant.participantId !== exceptParticipantId);
|
|
180
173
|
if (occupied)
|
|
181
174
|
throw flowError(COLLAB_ERRORS.conflict, `That agent is already registered to a seat in collaboration session ${occupied.sessionId}`);
|
|
182
175
|
}
|
|
@@ -191,30 +184,58 @@ export class CollabHub {
|
|
|
191
184
|
throw flowError(COLLAB_ERRORS.wrongPhase, 'Only a new implementer may be added to a finished review; the reviewer panel remains fixed for recheck');
|
|
192
185
|
if (!preparingRemediation && !this.registrationPhases(session).includes(session.phase))
|
|
193
186
|
throw flowError(COLLAB_ERRORS.wrongPhase, session.kind === 'scoring'
|
|
194
|
-
// A scoring panel never returns to `nominating` once the rubric is locked, so "wait for the next round"
|
|
195
|
-
|
|
196
|
-
|
|
187
|
+
? // A scoring panel never returns to `nominating` once the rubric is locked, so "wait for the next round"
|
|
188
|
+
// would be advice an operator cannot act on. Repairing the existing seat is the real path.
|
|
189
|
+
`A scoring panel is fixed once the rubric is locked (the session is in ${session.phase}). Repair the existing seat instead: POST /participants/{participantId}/binding to hand it to another agent, and POST /participants/{participantId}/budget if it ran out of tokens.`
|
|
197
190
|
: `A participant can only be registered in phase ${this.registrationPhases(session).join('/')}, but the session is in ${session.phase}. Wait for the next round, or rebind an existing seat.`);
|
|
198
191
|
this.assertAgentAvailable(input.agentId, sessionId);
|
|
199
|
-
const { participant, token } = this.store.createParticipant({
|
|
192
|
+
const { participant, token } = this.store.createParticipant({
|
|
193
|
+
sessionId,
|
|
194
|
+
role: input.role,
|
|
195
|
+
displayName: input.displayName,
|
|
196
|
+
model: input.model,
|
|
197
|
+
agentId: input.agentId,
|
|
198
|
+
tokenBudget: input.tokenBudget
|
|
199
|
+
});
|
|
200
200
|
// Only the hash is stored: the managed agent submits through the in-process bridge, and the plaintext is
|
|
201
201
|
// handed to the human exactly once, in this response.
|
|
202
|
-
this.record(sessionId, 'participant_added', {
|
|
202
|
+
this.record(sessionId, 'participant_added', {
|
|
203
|
+
participantId: participant.participantId,
|
|
204
|
+
role: participant.role,
|
|
205
|
+
displayName: participant.displayName,
|
|
206
|
+
agentId: participant.agentId,
|
|
207
|
+
model: participant.model
|
|
208
|
+
});
|
|
203
209
|
this.assignCurrentTask(session, participant);
|
|
204
210
|
return { participant, token, session };
|
|
205
211
|
}
|
|
206
212
|
/** Hands a late arrival the task the session is currently waiting for, so it never joins without an assignment. */
|
|
207
213
|
assignCurrentTask(session, participant) {
|
|
208
214
|
const task = session.kind === 'scoring'
|
|
209
|
-
?
|
|
210
|
-
|
|
211
|
-
:
|
|
212
|
-
|
|
215
|
+
? session.phase === 'nominating' && participant.role === 'reviewer'
|
|
216
|
+
? 'nominate_criteria'
|
|
217
|
+
: undefined
|
|
218
|
+
: session.phase === 'implementing' && participant.role === 'implementer'
|
|
219
|
+
? 'implement'
|
|
220
|
+
: session.phase === 'collecting' &&
|
|
221
|
+
participant.role !== 'moderator' &&
|
|
222
|
+
!(session.policy.implementationFirst && participant.role === 'implementer')
|
|
223
|
+
? participant.role === 'reviewer'
|
|
224
|
+
? 'file_findings'
|
|
225
|
+
: 'file_findings_optional'
|
|
213
226
|
: undefined;
|
|
214
227
|
if (!task)
|
|
215
228
|
return;
|
|
216
|
-
this.push(session.sessionId, participant.participantId, task, {
|
|
217
|
-
|
|
229
|
+
this.push(session.sessionId, participant.participantId, task, {
|
|
230
|
+
phase: session.phase,
|
|
231
|
+
round: session.round
|
|
232
|
+
});
|
|
233
|
+
this.record(session.sessionId, 'task_assigned', {
|
|
234
|
+
participantId: participant.participantId,
|
|
235
|
+
task,
|
|
236
|
+
phase: session.phase,
|
|
237
|
+
round: session.round
|
|
238
|
+
});
|
|
218
239
|
}
|
|
219
240
|
/**
|
|
220
241
|
* Hands a seat to another local agent: the bound one crashed, was deleted, or was picked by mistake. Without it
|
|
@@ -231,7 +252,12 @@ export class CollabHub {
|
|
|
231
252
|
const agentId = input.agentId.trim();
|
|
232
253
|
this.assertAgentAvailable(agentId, sessionId, participantId);
|
|
233
254
|
const { participant, token } = this.store.rebindParticipant(participantId, agentId, input.model);
|
|
234
|
-
this.record(sessionId, 'participant_rebound', {
|
|
255
|
+
this.record(sessionId, 'participant_rebound', {
|
|
256
|
+
participantId,
|
|
257
|
+
agentId: participant.agentId,
|
|
258
|
+
previousAgentId: current.agentId,
|
|
259
|
+
model: participant.model
|
|
260
|
+
});
|
|
235
261
|
// Whatever this seat owes has to reach the *new* agent, even when the old one was already prompted: a rebind
|
|
236
262
|
// exists precisely because that first delivery led nowhere. An entry that was never delivered is simply
|
|
237
263
|
// re-announced; one that was already acked is queued again for the replacement.
|
|
@@ -240,7 +266,13 @@ export class CollabHub {
|
|
|
240
266
|
if (last && session.status === 'active') {
|
|
241
267
|
if (!undelivered.length)
|
|
242
268
|
this.push(sessionId, participantId, last.type, last.payload);
|
|
243
|
-
this.record(sessionId, 'task_assigned', {
|
|
269
|
+
this.record(sessionId, 'task_assigned', {
|
|
270
|
+
participantId,
|
|
271
|
+
task: last.type,
|
|
272
|
+
phase: session.phase,
|
|
273
|
+
round: session.round,
|
|
274
|
+
reason: 'rebound'
|
|
275
|
+
});
|
|
244
276
|
}
|
|
245
277
|
return { participant, token };
|
|
246
278
|
}
|
|
@@ -253,16 +285,32 @@ export class CollabHub {
|
|
|
253
285
|
if (participant.sessionId !== sessionId)
|
|
254
286
|
throw flowError(COLLAB_ERRORS.participantNotFound, 'Participant not found in this session', 404);
|
|
255
287
|
if (input.tokenBudget <= participant.tokensUsed)
|
|
256
|
-
throw new ValidationError([
|
|
288
|
+
throw new ValidationError([
|
|
289
|
+
fieldError('tokenBudget', 'OUT_OF_RANGE', `The new budget must exceed the ${participant.tokensUsed} tokens already spent`)
|
|
290
|
+
]);
|
|
257
291
|
return this.raiseBudget(participantId, input.tokenBudget);
|
|
258
292
|
}
|
|
259
293
|
raiseBudget(participantId, tokenBudget, actor = 'human') {
|
|
260
294
|
const participant = this.store.getParticipant(participantId);
|
|
261
|
-
const updated = this.store.updateParticipant(participantId, {
|
|
262
|
-
|
|
295
|
+
const updated = this.store.updateParticipant(participantId, {
|
|
296
|
+
tokenBudget: Math.round(tokenBudget),
|
|
297
|
+
state: participant.state === 'budget_exhausted' ? 'active' : participant.state
|
|
298
|
+
});
|
|
299
|
+
this.record(updated.sessionId, 'budget_raised', {
|
|
300
|
+
participantId: updated.participantId,
|
|
301
|
+
tokenBudget: updated.tokenBudget,
|
|
302
|
+
tokensUsed: updated.tokensUsed,
|
|
303
|
+
previousState: participant.state
|
|
304
|
+
}, actor);
|
|
263
305
|
// The seat may be the one the phase is waiting for, and it now has a task it can actually submit.
|
|
264
306
|
for (const item of this.store.listInbox(participantId))
|
|
265
|
-
this.record(updated.sessionId, 'task_assigned', {
|
|
307
|
+
this.record(updated.sessionId, 'task_assigned', {
|
|
308
|
+
participantId,
|
|
309
|
+
task: item.type,
|
|
310
|
+
phase: this.store.getSession(updated.sessionId).phase,
|
|
311
|
+
round: this.store.getSession(updated.sessionId).round,
|
|
312
|
+
reason: 'budget_raised'
|
|
313
|
+
});
|
|
266
314
|
return updated;
|
|
267
315
|
}
|
|
268
316
|
/**
|
|
@@ -281,13 +329,19 @@ export class CollabHub {
|
|
|
281
329
|
const selected = [...new Set(input.implementerParticipantIds)], implementers = participants.filter(entry => entry.role === 'implementer' && entry.state === 'active');
|
|
282
330
|
if (input.mode === 'fix_then_review') {
|
|
283
331
|
if (selected.length !== 1)
|
|
284
|
-
throw new ValidationError([
|
|
332
|
+
throw new ValidationError([
|
|
333
|
+
fieldError('implementerParticipantIds', 'WRONG_COUNT', 'Select exactly one active implementer; parallel implementers cannot safely share one workspace')
|
|
334
|
+
]);
|
|
285
335
|
for (const participantId of selected)
|
|
286
336
|
if (!implementers.some(entry => entry.participantId === participantId))
|
|
287
|
-
throw new ValidationError([
|
|
337
|
+
throw new ValidationError([
|
|
338
|
+
fieldError('implementerParticipantIds', 'UNKNOWN_PARTICIPANT', `${participantId} is not an active implementer in this session`)
|
|
339
|
+
]);
|
|
288
340
|
}
|
|
289
341
|
else if (selected.length)
|
|
290
|
-
throw new ValidationError([
|
|
342
|
+
throw new ValidationError([
|
|
343
|
+
fieldError('implementerParticipantIds', 'UNEXPECTED', 'review_only does not assign an implementer')
|
|
344
|
+
]);
|
|
291
345
|
// Finished seats can be reused elsewhere. Reopening is the durability boundary where they become active again.
|
|
292
346
|
for (const participant of participants.filter(entry => entry.state === 'active'))
|
|
293
347
|
this.assertAgentAvailable(participant.agentId, sessionId, participant.participantId);
|
|
@@ -298,7 +352,11 @@ export class CollabHub {
|
|
|
298
352
|
const participantTokens = [];
|
|
299
353
|
for (const participant of participants.filter(entry => entry.state === 'active')) {
|
|
300
354
|
this.completeDelivery(participant.participantId, 'session_result');
|
|
301
|
-
participantTokens.push({
|
|
355
|
+
participantTokens.push({
|
|
356
|
+
participantId: participant.participantId,
|
|
357
|
+
displayName: participant.displayName,
|
|
358
|
+
token: this.store.rotateParticipantToken(participant.participantId)
|
|
359
|
+
});
|
|
302
360
|
}
|
|
303
361
|
if (input.mode === 'fix_then_review')
|
|
304
362
|
for (const implementer of implementers)
|
|
@@ -306,7 +364,14 @@ export class CollabHub {
|
|
|
306
364
|
this.store.markPhaseComplete(sessionId, round, 'implementing', implementer.participantId);
|
|
307
365
|
if (phase === 'collecting')
|
|
308
366
|
await this.captureBaseline({ ...session, phase, round, status: 'active' });
|
|
309
|
-
const updated = this.store.updateSession(sessionId, {
|
|
367
|
+
const updated = this.store.updateSession(sessionId, {
|
|
368
|
+
phase,
|
|
369
|
+
round,
|
|
370
|
+
debateRound: 0,
|
|
371
|
+
status: 'active',
|
|
372
|
+
stalled: undefined,
|
|
373
|
+
outcome: undefined
|
|
374
|
+
});
|
|
310
375
|
this.record(sessionId, 'review_reopened', { mode: input.mode, round, phase, implementerParticipantIds: selected }, actor);
|
|
311
376
|
this.dispatch(sessionId);
|
|
312
377
|
return { ...updated, participantTokens };
|
|
@@ -327,7 +392,12 @@ export class CollabHub {
|
|
|
327
392
|
if (!buildFirst)
|
|
328
393
|
await this.captureBaseline({ ...session, phase: 'collecting' });
|
|
329
394
|
const updated = this.store.updateSession(sessionId, { phase: buildFirst ? 'implementing' : 'collecting' });
|
|
330
|
-
this.record(sessionId, 'phase_changed', {
|
|
395
|
+
this.record(sessionId, 'phase_changed', {
|
|
396
|
+
from: 'draft',
|
|
397
|
+
to: updated.phase,
|
|
398
|
+
round: updated.round,
|
|
399
|
+
reason: buildFirst ? 'implementation_started' : 'round_opened'
|
|
400
|
+
});
|
|
331
401
|
this.dispatch(sessionId);
|
|
332
402
|
return this.store.getSession(sessionId);
|
|
333
403
|
}
|
|
@@ -349,10 +419,21 @@ export class CollabHub {
|
|
|
349
419
|
if (!waitingOn(this.snapshot(session.sessionId)).includes(participant.participantId))
|
|
350
420
|
throw flowError(COLLAB_ERRORS.forbidden, 'This implementer is not assigned to the current fix cycle', 403);
|
|
351
421
|
this.store.markPhaseComplete(session.sessionId, session.round, 'implementing', participant.participantId);
|
|
352
|
-
this.record(session.sessionId, 'implementation_ready', {
|
|
422
|
+
this.record(session.sessionId, 'implementation_ready', {
|
|
423
|
+
participantId: participant.participantId,
|
|
424
|
+
summary: input.summary,
|
|
425
|
+
codeRef: input.codeRef,
|
|
426
|
+
changedFiles: input.changes.length,
|
|
427
|
+
trigger: 'self_reported'
|
|
428
|
+
}, participant.participantId);
|
|
353
429
|
await this.settle(session.sessionId);
|
|
354
430
|
const current = this.store.getSession(session.sessionId);
|
|
355
|
-
const response = {
|
|
431
|
+
const response = {
|
|
432
|
+
accepted: true,
|
|
433
|
+
phase: current.phase,
|
|
434
|
+
round: current.round,
|
|
435
|
+
waitingOn: waitingOn(this.snapshot(session.sessionId))
|
|
436
|
+
};
|
|
356
437
|
this.finish(participant, input.clientRequestId, body, input.usage, response);
|
|
357
438
|
return response;
|
|
358
439
|
}
|
|
@@ -364,7 +445,9 @@ export class CollabHub {
|
|
|
364
445
|
if (pendingPanel.length && !input.force)
|
|
365
446
|
throw flowError(COLLAB_ERRORS.wrongPhase, `Still waiting on ${pendingPanel.length} panelist(s): ${pendingPanel.join(', ')}. Pass force=true to override.`);
|
|
366
447
|
if (pendingPanel.length && !input.reason.trim())
|
|
367
|
-
throw new ValidationError([
|
|
448
|
+
throw new ValidationError([
|
|
449
|
+
fieldError('reason', 'REQUIRED', 'A forced advance must state why the pending panelists are being skipped')
|
|
450
|
+
]);
|
|
368
451
|
// Anything else that blocks the panel (no reviewer registered, a question still on the human's desk) has to
|
|
369
452
|
// be reported too: settleScoring() returns quietly, so the operator would get 200 and an unchanged session.
|
|
370
453
|
if (!input.force)
|
|
@@ -378,7 +461,9 @@ export class CollabHub {
|
|
|
378
461
|
if (pending.length && !input.force)
|
|
379
462
|
throw flowError(COLLAB_ERRORS.wrongPhase, `Still waiting on ${pending.length} participant(s): ${pending.join(', ')}. Pass force=true to override.`);
|
|
380
463
|
if (pending.length && !input.reason.trim())
|
|
381
|
-
throw new ValidationError([
|
|
464
|
+
throw new ValidationError([
|
|
465
|
+
fieldError('reason', 'REQUIRED', 'A forced advance must state why the pending participants are being skipped')
|
|
466
|
+
]);
|
|
382
467
|
const result = nextPhase(snapshot, { forced: input.force });
|
|
383
468
|
await this.applyPhase(session, result, { forced: input.force, reason: input.reason, actor });
|
|
384
469
|
// A forced step may unblock purely automatic phases (consolidating), so let the machine run on.
|
|
@@ -390,25 +475,49 @@ export class CollabHub {
|
|
|
390
475
|
const allPending = session.kind === 'scoring' ? scoringWaitingOn(this.scoringSnapshot(sessionId)) : waitingOn(snapshot);
|
|
391
476
|
const crossVoters = snapshot ? pendingCrossVoters(snapshot) : [], owed = new Set([...allPending, ...crossVoters]);
|
|
392
477
|
const wanted = input.participantIds?.length ? new Set(input.participantIds) : undefined, pending = [...owed].filter(participantId => !wanted || wanted.has(participantId));
|
|
393
|
-
const participants = this.store.listParticipants(sessionId), busy = pending.filter(participantId => {
|
|
478
|
+
const participants = this.store.listParticipants(sessionId), busy = pending.filter(participantId => {
|
|
479
|
+
const participant = participants.find(entry => entry.participantId === participantId);
|
|
480
|
+
return (participant &&
|
|
481
|
+
['starting', 'streaming', 'waiting_for_user', 'stopping'].includes(this.options.agentStatus?.(participant.agentId) ?? ''));
|
|
482
|
+
});
|
|
394
483
|
const retryable = pending.filter(participantId => !busy.includes(participantId));
|
|
395
|
-
const baseTask = session.kind === 'scoring'
|
|
396
|
-
|
|
484
|
+
const baseTask = session.kind === 'scoring'
|
|
485
|
+
? {
|
|
486
|
+
nominating: 'nominate_criteria',
|
|
487
|
+
voting: 'vote_on_criteria',
|
|
488
|
+
scoring: 'score_rubric',
|
|
489
|
+
debating: 'debate_contested_scores',
|
|
490
|
+
rescoring: 'rescore_contested'
|
|
491
|
+
}[session.phase]
|
|
492
|
+
: {
|
|
493
|
+
implementing: 'implement',
|
|
494
|
+
collecting: 'file_findings',
|
|
495
|
+
validating: 'validate_issues',
|
|
496
|
+
merge_voting: 'vote_on_merges',
|
|
497
|
+
issue_discussing: 'defend_approved_issues',
|
|
498
|
+
issue_reconsidering: 'reconsider_issue_votes'
|
|
499
|
+
}[session.phase];
|
|
397
500
|
if (!owed.size)
|
|
398
501
|
throw flowError(COLLAB_ERRORS.wrongPhase, 'Nobody is currently waiting to submit');
|
|
399
502
|
if (!baseTask)
|
|
400
503
|
throw flowError(COLLAB_ERRORS.wrongPhase, `Phase ${session.phase} has no retryable Agent task`);
|
|
401
504
|
const nonce = this.now();
|
|
402
505
|
for (const participantId of retryable) {
|
|
403
|
-
const taskBase = crossVoters.includes(participantId) && !allPending.includes(participantId)
|
|
506
|
+
const taskBase = crossVoters.includes(participantId) && !allPending.includes(participantId)
|
|
507
|
+
? 'validate_issues'
|
|
508
|
+
: baseTask;
|
|
404
509
|
const task = `${taskBase}#retry-${nonce}`;
|
|
405
510
|
this.push(sessionId, participantId, task, { phase: session.phase, round: session.round, retry: true });
|
|
406
511
|
this.record(sessionId, 'task_assigned', { participantId, task, phase: session.phase, round: session.round, retry: true }, actor);
|
|
407
512
|
}
|
|
408
513
|
return { retried: retryable, skippedBusy: busy, phase: session.phase };
|
|
409
514
|
}
|
|
410
|
-
listEscalations(filter = {}) {
|
|
411
|
-
|
|
515
|
+
listEscalations(filter = {}) {
|
|
516
|
+
return this.store.listEscalations(filter);
|
|
517
|
+
}
|
|
518
|
+
getEscalation(escalationId) {
|
|
519
|
+
return this.store.getEscalation(escalationId);
|
|
520
|
+
}
|
|
412
521
|
/**
|
|
413
522
|
* A human ruling is terminal. Every escalation kind carries its own structured action:
|
|
414
523
|
* an issue dispute rules on the issue, an exhausted budget can be raised, a deadlock can raise the round cap,
|
|
@@ -429,8 +538,12 @@ export class CollabHub {
|
|
|
429
538
|
// instead of consuming the only remedy the human has.
|
|
430
539
|
const errors = [];
|
|
431
540
|
let budgetTarget, tokenBudget = 0;
|
|
432
|
-
if ('tokenBudget' in extra ||
|
|
433
|
-
|
|
541
|
+
if ('tokenBudget' in extra ||
|
|
542
|
+
(escalation.kind === 'budget_exhausted' && extra.tokenBudget !== undefined)) {
|
|
543
|
+
budgetTarget =
|
|
544
|
+
escalation.kind === 'budget_exhausted' && escalation.refId
|
|
545
|
+
? this.store.getParticipant(escalation.refId)
|
|
546
|
+
: undefined;
|
|
434
547
|
if (!budgetTarget)
|
|
435
548
|
errors.push(fieldError('extra.tokenBudget', 'UNEXPECTED', 'Only a budget_exhausted escalation can raise a token budget'));
|
|
436
549
|
else if (typeof extra.tokenBudget !== 'number' || !Number.isFinite(extra.tokenBudget))
|
|
@@ -461,9 +574,23 @@ export class CollabHub {
|
|
|
461
574
|
const ruledRound = input.issueDecision === 'reopen' && session.kind === 'review' ? session.round : outcome.round;
|
|
462
575
|
this.store.updateIssue(issue.issueId, { status: outcome.status, round: ruledRound });
|
|
463
576
|
if (input.issueDecision === 'reopen' && session.policy.consensusReview)
|
|
464
|
-
for (const reviewer of this.store
|
|
465
|
-
|
|
466
|
-
|
|
577
|
+
for (const reviewer of this.store
|
|
578
|
+
.listParticipants(session.sessionId)
|
|
579
|
+
.filter(entry => entry.role === 'reviewer' && entry.state !== 'left'))
|
|
580
|
+
this.store.saveIssueVote({
|
|
581
|
+
sessionId: session.sessionId,
|
|
582
|
+
issueId: issue.issueId,
|
|
583
|
+
participantId: reviewer.participantId,
|
|
584
|
+
round: session.round,
|
|
585
|
+
consensusRound: session.debateRound + 1,
|
|
586
|
+
stance: 'approve',
|
|
587
|
+
rationale: `Human ruled this a valid issue: ${input.rationale}`
|
|
588
|
+
});
|
|
589
|
+
this.store.addIssueMessage(issue.issueId, outcome.round, resolvedBy, 'ruling', {
|
|
590
|
+
decision: input.decision,
|
|
591
|
+
rationale: input.rationale,
|
|
592
|
+
issueDecision: input.issueDecision
|
|
593
|
+
});
|
|
467
594
|
this.record(escalation.sessionId, 'issue_ruled', { issueId: issue.issueId, status: outcome.status, decision: input.issueDecision }, resolvedBy);
|
|
468
595
|
applied.push(`issue:${input.issueDecision}`);
|
|
469
596
|
}
|
|
@@ -479,7 +606,13 @@ export class CollabHub {
|
|
|
479
606
|
this.record(escalation.sessionId, 'policy_updated', { policy }, resolvedBy);
|
|
480
607
|
applied.push(`maxTotalRounds:${maxTotalRounds}`);
|
|
481
608
|
}
|
|
482
|
-
const resolved = this.store.resolveEscalation(escalationId, {
|
|
609
|
+
const resolved = this.store.resolveEscalation(escalationId, {
|
|
610
|
+
decision: input.decision,
|
|
611
|
+
rationale: input.rationale,
|
|
612
|
+
issueDecision: input.issueDecision,
|
|
613
|
+
extra: input.extra,
|
|
614
|
+
applied
|
|
615
|
+
}, resolvedBy);
|
|
483
616
|
// `applied` is on the event so the board can show what a ruling actually changed, not just what it said.
|
|
484
617
|
this.record(escalation.sessionId, 'escalation_resolved', { escalationId, kind: escalation.kind, decision: input.decision, rationale: input.rationale, applied }, resolvedBy);
|
|
485
618
|
// A scoring session must be settled by the scoring machine; the review machine has different phases.
|
|
@@ -494,18 +627,26 @@ export class CollabHub {
|
|
|
494
627
|
const participant = token ? this.store.findParticipantByToken(token) : undefined;
|
|
495
628
|
if (!participant)
|
|
496
629
|
throw flowError(COLLAB_ERRORS.forbidden, 'Unknown participant token', 401);
|
|
497
|
-
return this.store.updateParticipant(participant.participantId, {
|
|
630
|
+
return this.store.updateParticipant(participant.participantId, {
|
|
631
|
+
lastSeenAt: new Date(this.now()).toISOString()
|
|
632
|
+
});
|
|
498
633
|
}
|
|
499
634
|
/** Internal bridge identity lookup. A collaboration Agent is constrained to exactly one active seat. */
|
|
500
635
|
participantForAgent(agentId) {
|
|
501
636
|
const matches = this.store.findActiveParticipantsByAgent(agentId);
|
|
502
637
|
if (matches.length !== 1)
|
|
503
|
-
throw flowError(COLLAB_ERRORS.forbidden, matches.length
|
|
504
|
-
|
|
638
|
+
throw flowError(COLLAB_ERRORS.forbidden, matches.length
|
|
639
|
+
? 'This collaboration Agent has more than one active seat'
|
|
640
|
+
: 'This Agent has no active collaboration seat', 403);
|
|
641
|
+
return this.store.updateParticipant(matches[0].participantId, {
|
|
642
|
+
lastSeenAt: new Date(this.now()).toISOString()
|
|
643
|
+
});
|
|
505
644
|
}
|
|
506
645
|
/** A wake-up is durable until the extension actually asks for its task; merely queueing a Pi follow-up is not delivery. */
|
|
507
646
|
claimTaskForAgent(agentId) {
|
|
508
|
-
const participant = this.participantForAgent(agentId), task = this.digest(participant), pending = this.store
|
|
647
|
+
const participant = this.participantForAgent(agentId), task = this.digest(participant), pending = this.store
|
|
648
|
+
.listInbox(participant.participantId)
|
|
649
|
+
.filter(item => item.type !== 'session_result');
|
|
509
650
|
if (pending.length) {
|
|
510
651
|
this.store.ackInbox(participant.participantId, pending.map(item => item.itemId));
|
|
511
652
|
this.record(participant.sessionId, 'task_collected', { participantId: participant.participantId, tasks: pending.map(item => item.type) }, participant.participantId);
|
|
@@ -530,9 +671,14 @@ export class CollabHub {
|
|
|
530
671
|
const accepted = [], rejected = [];
|
|
531
672
|
// A later review round verifies the reporter's previously confirmed findings as part of the same blind task.
|
|
532
673
|
// `reviewComplete` is rejected unless every owed recheck has been explicitly resolved or kept open.
|
|
533
|
-
const alreadyRechecked = new Set(this.store.listIssues(session.sessionId).flatMap(issue => this.store
|
|
534
|
-
.
|
|
535
|
-
|
|
674
|
+
const alreadyRechecked = new Set(this.store.listIssues(session.sessionId).flatMap(issue => this.store
|
|
675
|
+
.listIssueMessages(issue.issueId)
|
|
676
|
+
.filter(message => message.kind === 'recheck' &&
|
|
677
|
+
Number(message.payload.reviewRound) === session.round &&
|
|
678
|
+
message.authorId === participant.participantId)
|
|
679
|
+
.map(message => issue.issueId)));
|
|
680
|
+
const owedRechecks = this.store
|
|
681
|
+
.listIssues(session.sessionId, { status: ['confirmed'], reporterId: participant.participantId })
|
|
536
682
|
.filter(issue => issue.round < session.round && !alreadyRechecked.has(issue.issueId));
|
|
537
683
|
const submittedRechecks = input.rechecks.map(entry => entry.issueId), recheckErrors = [];
|
|
538
684
|
for (const [index, entry] of input.rechecks.entries()) {
|
|
@@ -553,7 +699,12 @@ export class CollabHub {
|
|
|
553
699
|
const issue = owedRechecks.find(candidate => candidate.issueId === entry.issueId);
|
|
554
700
|
if (entry.outcome === 'resolved')
|
|
555
701
|
this.store.updateIssue(issue.issueId, { status: 'resolved' });
|
|
556
|
-
this.store.addIssueMessage(issue.issueId, session.round, participant.participantId, 'recheck', {
|
|
702
|
+
this.store.addIssueMessage(issue.issueId, session.round, participant.participantId, 'recheck', {
|
|
703
|
+
reviewRound: session.round,
|
|
704
|
+
outcome: entry.outcome,
|
|
705
|
+
rationale: entry.rationale,
|
|
706
|
+
baselineId: baseline.baselineId
|
|
707
|
+
});
|
|
557
708
|
acceptedRechecks.push({ issueId: issue.issueId, outcome: entry.outcome });
|
|
558
709
|
this.record(session.sessionId, 'issue_rechecked', { issueId: issue.issueId, outcome: entry.outcome, reviewRound: session.round }, participant.participantId);
|
|
559
710
|
}
|
|
@@ -562,19 +713,44 @@ export class CollabHub {
|
|
|
562
713
|
// is valid, so with no implementer the reporter itself is the harmless default owner.
|
|
563
714
|
const targetId = finding.targetParticipantId ?? defaultTarget?.participantId ?? participant.participantId;
|
|
564
715
|
if (!participants.some(entry => entry.participantId === targetId)) {
|
|
565
|
-
rejected.push({
|
|
716
|
+
rejected.push({
|
|
717
|
+
externalId: finding.externalId,
|
|
718
|
+
code: COLLAB_ERRORS.participantNotFound,
|
|
719
|
+
message: `Unknown targetParticipantId ${targetId}`
|
|
720
|
+
});
|
|
566
721
|
continue;
|
|
567
722
|
}
|
|
568
|
-
const issue = this.store.createIssue({
|
|
569
|
-
|
|
570
|
-
|
|
723
|
+
const issue = this.store.createIssue({
|
|
724
|
+
sessionId: session.sessionId,
|
|
725
|
+
externalId: finding.externalId,
|
|
726
|
+
reporterId: participant.participantId,
|
|
727
|
+
targetParticipantId: targetId,
|
|
728
|
+
title: finding.title,
|
|
729
|
+
severity: finding.severity,
|
|
730
|
+
category: finding.category,
|
|
731
|
+
requiredAction: finding.requiredAction,
|
|
732
|
+
confidence: finding.confidence,
|
|
733
|
+
location: finding.location,
|
|
734
|
+
evidence: finding.evidence,
|
|
735
|
+
impact: finding.impact,
|
|
736
|
+
suggestion: finding.suggestion,
|
|
737
|
+
baselineId: baseline.baselineId,
|
|
738
|
+
round: session.round
|
|
739
|
+
});
|
|
571
740
|
accepted.push({ externalId: finding.externalId, issueId: issue.issueId });
|
|
572
741
|
this.record(session.sessionId, 'issue_opened', { issueId: issue.issueId, title: issue.title, severity: issue.severity, targetParticipantId: targetId }, participant.participantId);
|
|
573
742
|
}
|
|
574
743
|
if (input.reviewComplete)
|
|
575
744
|
this.store.markPhaseComplete(session.sessionId, session.round, 'collecting', participant.participantId);
|
|
576
745
|
const duplicates = this.possibleDuplicates(session.sessionId, accepted.map(entry => entry.issueId), participant);
|
|
577
|
-
const response = {
|
|
746
|
+
const response = {
|
|
747
|
+
accepted,
|
|
748
|
+
rejected,
|
|
749
|
+
rechecks: acceptedRechecks,
|
|
750
|
+
possibleDuplicates: duplicates,
|
|
751
|
+
reviewComplete: input.reviewComplete,
|
|
752
|
+
round: session.round
|
|
753
|
+
};
|
|
578
754
|
this.finish(participant, input.clientRequestId, body, input.usage, response);
|
|
579
755
|
this.record(session.sessionId, 'findings_submitted', { count: accepted.length, rejected: rejected.length, reviewComplete: input.reviewComplete }, participant.participantId);
|
|
580
756
|
await this.settle(session.sessionId);
|
|
@@ -587,7 +763,9 @@ export class CollabHub {
|
|
|
587
763
|
return cached;
|
|
588
764
|
const session = this.store.getSession(participant.sessionId), snapshot = this.snapshot(session.sessionId);
|
|
589
765
|
assertCapability(this.toFlowParticipant(participant), 'vote');
|
|
590
|
-
const collected = snapshot.completions.some(entry => entry.phase === 'collecting' &&
|
|
766
|
+
const collected = snapshot.completions.some(entry => entry.phase === 'collecting' &&
|
|
767
|
+
entry.round === session.round &&
|
|
768
|
+
entry.participantId === participant.participantId);
|
|
591
769
|
if (session.phase === 'collecting') {
|
|
592
770
|
if (!session.policy.consensusReview)
|
|
593
771
|
throw flowError(COLLAB_ERRORS.wrongPhase, 'Issue votes are only accepted after collection when consensus review is enabled');
|
|
@@ -597,9 +775,17 @@ export class CollabHub {
|
|
|
597
775
|
else if (!['validating', 'issue_reconsidering'].includes(session.phase))
|
|
598
776
|
throw flowError(COLLAB_ERRORS.wrongPhase, `Issue votes are only accepted in validating or issue_reconsidering, but the session is in ${session.phase}`);
|
|
599
777
|
const consensus = issueConsensus(snapshot), owedNow = session.phase === 'issue_reconsidering'
|
|
600
|
-
? consensus
|
|
778
|
+
? consensus
|
|
779
|
+
.filter(entry => activeContestedIssueIds(snapshot).includes(entry.issueId) &&
|
|
780
|
+
entry.rejecters.includes(participant.participantId))
|
|
781
|
+
.map(entry => entry.issueId)
|
|
601
782
|
: owedIssueVoteIds(snapshot, participant.participantId);
|
|
602
|
-
const votable = session.phase === 'issue_reconsidering'
|
|
783
|
+
const votable = session.phase === 'issue_reconsidering'
|
|
784
|
+
? owedNow
|
|
785
|
+
: this.store
|
|
786
|
+
.listIssues(session.sessionId, { status: ['open'], round: session.round })
|
|
787
|
+
.filter(issue => issue.reporterId !== participant.participantId)
|
|
788
|
+
.map(issue => issue.issueId);
|
|
603
789
|
const submitted = input.votes.map(vote => vote.issueId), errors = [], dropped = new Set();
|
|
604
790
|
// A missing vote is not a bad request. Findings keep arriving while a reviewer reads code, so the owed set
|
|
605
791
|
// grows underneath it and demanding the set as it stands at submit time threw away a whole turn of work
|
|
@@ -631,22 +817,55 @@ export class CollabHub {
|
|
|
631
817
|
throw new ValidationError(errors);
|
|
632
818
|
const counted = input.votes.filter(vote => !dropped.has(vote.issueId)), acceptedIds = counted.map(vote => vote.issueId);
|
|
633
819
|
for (const vote of counted)
|
|
634
|
-
this.store.saveIssueVote({
|
|
820
|
+
this.store.saveIssueVote({
|
|
821
|
+
sessionId: session.sessionId,
|
|
822
|
+
issueId: vote.issueId,
|
|
823
|
+
participantId: participant.participantId,
|
|
824
|
+
round: session.round,
|
|
825
|
+
consensusRound: session.phase === 'validating' ? 0 : session.debateRound,
|
|
826
|
+
stance: vote.stance,
|
|
827
|
+
rationale: vote.rationale
|
|
828
|
+
});
|
|
635
829
|
const proposalIds = [];
|
|
636
830
|
for (const proposal of input.mergeProposals) {
|
|
637
831
|
const issueIds = [...new Set(proposal.issueIds)];
|
|
638
|
-
if (issueIds.length < 2 ||
|
|
639
|
-
|
|
640
|
-
|
|
832
|
+
if (issueIds.length < 2 ||
|
|
833
|
+
issueIds.some(issueId => !this.store.findIssueInSession(session.sessionId, issueId)))
|
|
834
|
+
throw new ValidationError([
|
|
835
|
+
fieldError('mergeProposals', 'UNKNOWN_ISSUE', 'Every merge proposal needs at least two issues from this session')
|
|
836
|
+
]);
|
|
837
|
+
proposalIds.push(this.store.createMergeProposal({
|
|
838
|
+
sessionId: session.sessionId,
|
|
839
|
+
round: session.round,
|
|
840
|
+
issueIds,
|
|
841
|
+
participantId: participant.participantId,
|
|
842
|
+
rationale: proposal.rationale
|
|
843
|
+
}).proposalId);
|
|
641
844
|
}
|
|
642
845
|
// Completion is measured against the ballot box, not against the request: a reviewer is done when nothing
|
|
643
846
|
// is owed any more, whichever call finally covered it.
|
|
644
847
|
const remaining = this.owedVotesAfter(session, participant, acceptedIds, owedNow);
|
|
645
848
|
if (input.complete && !remaining.length && session.phase !== 'collecting')
|
|
646
|
-
this.store.markPhaseComplete(session.sessionId, session.round, session.phase === 'issue_reconsidering'
|
|
647
|
-
|
|
849
|
+
this.store.markPhaseComplete(session.sessionId, session.round, session.phase === 'issue_reconsidering'
|
|
850
|
+
? `issue_reconsidering:${session.debateRound}`
|
|
851
|
+
: session.phase, participant.participantId);
|
|
852
|
+
const response = {
|
|
853
|
+
accepted: acceptedIds,
|
|
854
|
+
dropped: [...dropped],
|
|
855
|
+
mergeProposals: proposalIds,
|
|
856
|
+
complete: input.complete && !remaining.length,
|
|
857
|
+
stillOwed: remaining,
|
|
858
|
+
consensusRound: session.debateRound
|
|
859
|
+
};
|
|
648
860
|
this.finish(participant, input.clientRequestId, body, input.usage, response);
|
|
649
|
-
this.record(session.sessionId, 'issue_votes_submitted', {
|
|
861
|
+
this.record(session.sessionId, 'issue_votes_submitted', {
|
|
862
|
+
count: acceptedIds.length,
|
|
863
|
+
dropped: dropped.size,
|
|
864
|
+
mergeProposals: proposalIds,
|
|
865
|
+
phase: session.phase,
|
|
866
|
+
consensusRound: session.debateRound,
|
|
867
|
+
stillOwed: remaining.length
|
|
868
|
+
}, participant.participantId);
|
|
650
869
|
await this.settle(session.sessionId);
|
|
651
870
|
// Whatever this seat still owes has to come back to it as a task, or the session waits on a reviewer that
|
|
652
871
|
// believes it is finished.
|
|
@@ -661,7 +880,9 @@ export class CollabHub {
|
|
|
661
880
|
}
|
|
662
881
|
requeueVotes(sessionId, participantId, owed) {
|
|
663
882
|
const session = this.store.findSession(sessionId);
|
|
664
|
-
if (!session ||
|
|
883
|
+
if (!session ||
|
|
884
|
+
session.status !== 'active' ||
|
|
885
|
+
!['collecting', 'validating', 'issue_reconsidering'].includes(session.phase))
|
|
665
886
|
return;
|
|
666
887
|
const prefix = session.phase === 'issue_reconsidering' ? 'reconsider_issue_votes' : 'validate_issues';
|
|
667
888
|
const task = `${prefix}#${owed}`;
|
|
@@ -670,7 +891,13 @@ export class CollabHub {
|
|
|
670
891
|
if (this.store.listInbox(participantId).some(item => item.type.split('#')[0] === prefix))
|
|
671
892
|
return;
|
|
672
893
|
this.push(sessionId, participantId, task, { phase: session.phase, round: session.round, owed });
|
|
673
|
-
this.record(sessionId, 'task_assigned', {
|
|
894
|
+
this.record(sessionId, 'task_assigned', {
|
|
895
|
+
participantId,
|
|
896
|
+
task,
|
|
897
|
+
phase: session.phase,
|
|
898
|
+
round: session.round,
|
|
899
|
+
remainder: true
|
|
900
|
+
});
|
|
674
901
|
}
|
|
675
902
|
async submitMergeVotes(participant, body) {
|
|
676
903
|
const input = parse(mergeVotesRequest, body), cached = this.replay(participant, input.clientRequestId);
|
|
@@ -680,7 +907,9 @@ export class CollabHub {
|
|
|
680
907
|
assertCapability(this.toFlowParticipant(participant), 'merge');
|
|
681
908
|
if (session.phase !== 'merge_voting')
|
|
682
909
|
throw flowError(COLLAB_ERRORS.wrongPhase, `Merge votes are only accepted in phase merge_voting, but the session is in ${session.phase}`);
|
|
683
|
-
const proposals = (snapshot.mergeProposals ?? []).filter(proposal => proposal.round === session.round), expected = proposals
|
|
910
|
+
const proposals = (snapshot.mergeProposals ?? []).filter(proposal => proposal.round === session.round), expected = proposals
|
|
911
|
+
.filter(proposal => !proposal.votes.some(vote => vote.participantId === participant.participantId))
|
|
912
|
+
.map(proposal => proposal.proposalId), submitted = input.votes.map(vote => vote.proposalId), errors = [];
|
|
684
913
|
for (const proposalId of expected)
|
|
685
914
|
if (!submitted.includes(proposalId))
|
|
686
915
|
errors.push(fieldError('votes', 'REQUIRED', `A vote for merge proposal ${proposalId} is required`));
|
|
@@ -693,7 +922,12 @@ export class CollabHub {
|
|
|
693
922
|
if (errors.length)
|
|
694
923
|
throw new ValidationError(errors);
|
|
695
924
|
for (const vote of input.votes)
|
|
696
|
-
this.store.saveMergeVote({
|
|
925
|
+
this.store.saveMergeVote({
|
|
926
|
+
proposalId: vote.proposalId,
|
|
927
|
+
participantId: participant.participantId,
|
|
928
|
+
stance: vote.stance,
|
|
929
|
+
rationale: vote.rationale
|
|
930
|
+
});
|
|
697
931
|
if (input.complete)
|
|
698
932
|
this.store.markPhaseComplete(session.sessionId, session.round, 'merge_voting', participant.participantId);
|
|
699
933
|
const response = { accepted: submitted, complete: input.complete };
|
|
@@ -711,7 +945,9 @@ export class CollabHub {
|
|
|
711
945
|
if (session.phase !== 'issue_discussing')
|
|
712
946
|
throw flowError(COLLAB_ERRORS.wrongPhase, `Issue discussions are only accepted in phase issue_discussing, but the session is in ${session.phase}`);
|
|
713
947
|
const active = new Set(activeContestedIssueIds(snapshot));
|
|
714
|
-
const expected = issueConsensus(snapshot)
|
|
948
|
+
const expected = issueConsensus(snapshot)
|
|
949
|
+
.filter(entry => active.has(entry.issueId) && entry.supporters.includes(participant.participantId))
|
|
950
|
+
.map(entry => entry.issueId);
|
|
715
951
|
const errors = [];
|
|
716
952
|
// Withdrawals are validated and applied first: a reporter that no longer stands by a finding must be able to
|
|
717
953
|
// say so in the same call that defends the rest. Two calls are impossible for an agent, because the
|
|
@@ -767,22 +1003,44 @@ export class CollabHub {
|
|
|
767
1003
|
if (!withdrawn.has(entry.issueId))
|
|
768
1004
|
continue;
|
|
769
1005
|
const issue = this.store.getIssue(entry.issueId);
|
|
770
|
-
const outcome = applyWithdraw({
|
|
1006
|
+
const outcome = applyWithdraw({
|
|
1007
|
+
issue: this.toFlowIssue(issue),
|
|
1008
|
+
actor: this.toFlowParticipant(participant)
|
|
1009
|
+
});
|
|
771
1010
|
this.store.updateIssue(issue.issueId, { status: outcome.status });
|
|
772
|
-
this.store.addIssueMessage(issue.issueId, session.round, participant.participantId, 'discussion', {
|
|
1011
|
+
this.store.addIssueMessage(issue.issueId, session.round, participant.participantId, 'discussion', {
|
|
1012
|
+
argument: `Withdrawn by the reporter: ${entry.rationale}`,
|
|
1013
|
+
withdrawn: true,
|
|
1014
|
+
consensusRound: session.debateRound
|
|
1015
|
+
});
|
|
773
1016
|
this.record(session.sessionId, 'issue_withdrawn', { issueId: issue.issueId, rationale: entry.rationale, consensusRound: session.debateRound }, participant.participantId);
|
|
774
1017
|
}
|
|
775
1018
|
for (const entry of input.discussions) {
|
|
776
1019
|
if (withdrawn.has(entry.issueId) || !expected.includes(entry.issueId))
|
|
777
1020
|
continue;
|
|
778
|
-
this.store.addIssueMessage(entry.issueId, session.round, participant.participantId, 'discussion', {
|
|
1021
|
+
this.store.addIssueMessage(entry.issueId, session.round, participant.participantId, 'discussion', {
|
|
1022
|
+
argument: entry.argument,
|
|
1023
|
+
respondingTo: entry.respondingTo,
|
|
1024
|
+
consensusRound: session.debateRound
|
|
1025
|
+
});
|
|
779
1026
|
accepted.push(entry.issueId);
|
|
780
1027
|
}
|
|
781
1028
|
if (input.complete)
|
|
782
1029
|
this.store.markPhaseComplete(session.sessionId, session.round, `issue_discussing:${session.debateRound}`, participant.participantId);
|
|
783
|
-
const response = {
|
|
1030
|
+
const response = {
|
|
1031
|
+
accepted,
|
|
1032
|
+
withdrawn: [...withdrawn],
|
|
1033
|
+
alreadyWithdrawn: [...alreadyClosed],
|
|
1034
|
+
complete: input.complete,
|
|
1035
|
+
consensusRound: session.debateRound
|
|
1036
|
+
};
|
|
784
1037
|
this.finish(participant, input.clientRequestId, body, input.usage, response);
|
|
785
|
-
this.record(session.sessionId, 'issue_discussions_submitted', {
|
|
1038
|
+
this.record(session.sessionId, 'issue_discussions_submitted', {
|
|
1039
|
+
count: accepted.length,
|
|
1040
|
+
withdrawn: withdrawn.size,
|
|
1041
|
+
alreadyWithdrawn: alreadyClosed.size,
|
|
1042
|
+
consensusRound: session.debateRound
|
|
1043
|
+
}, participant.participantId);
|
|
786
1044
|
await this.settle(session.sessionId);
|
|
787
1045
|
return response;
|
|
788
1046
|
}
|
|
@@ -790,7 +1048,10 @@ export class CollabHub {
|
|
|
790
1048
|
const session = this.store.getSession(participant.sessionId), issue = this.store.findIssueInSession(session.sessionId, issueId);
|
|
791
1049
|
if (!issue)
|
|
792
1050
|
throw flowError(COLLAB_ERRORS.issueNotFound, 'Issue not found in this session', 404);
|
|
793
|
-
const outcome = applyWithdraw({
|
|
1051
|
+
const outcome = applyWithdraw({
|
|
1052
|
+
issue: this.toFlowIssue(issue),
|
|
1053
|
+
actor: this.toFlowParticipant(participant)
|
|
1054
|
+
});
|
|
794
1055
|
const updated = this.store.updateIssue(issue.issueId, { status: outcome.status });
|
|
795
1056
|
this.record(session.sessionId, 'issue_withdrawn', { issueId: issue.issueId }, participant.participantId);
|
|
796
1057
|
await this.settle(session.sessionId);
|
|
@@ -810,18 +1071,41 @@ export class CollabHub {
|
|
|
810
1071
|
const issue = this.store.findIssueInSession(session.sessionId, input.refId);
|
|
811
1072
|
if (!issue)
|
|
812
1073
|
throw flowError(COLLAB_ERRORS.issueNotFound, 'Issue not found in this session', 404);
|
|
813
|
-
const consensusPhase = [
|
|
1074
|
+
const consensusPhase = [
|
|
1075
|
+
'validating',
|
|
1076
|
+
'merge_voting',
|
|
1077
|
+
'issue_discussing',
|
|
1078
|
+
'issue_reconsidering'
|
|
1079
|
+
].includes(session.phase);
|
|
814
1080
|
if (consensusPhase && participant.role === 'reviewer')
|
|
815
1081
|
this.store.updateIssue(issue.issueId, { status: 'escalated' });
|
|
816
1082
|
else {
|
|
817
|
-
const outcome = applyEscalation({
|
|
1083
|
+
const outcome = applyEscalation({
|
|
1084
|
+
issue: this.toFlowIssue(issue),
|
|
1085
|
+
actor: this.toFlowParticipant(participant)
|
|
1086
|
+
});
|
|
818
1087
|
this.store.updateIssue(issue.issueId, { status: outcome.status, round: outcome.round });
|
|
819
1088
|
}
|
|
820
1089
|
}
|
|
821
|
-
const escalation = this.store.createEscalation({
|
|
822
|
-
|
|
1090
|
+
const escalation = this.store.createEscalation({
|
|
1091
|
+
sessionId: session.sessionId,
|
|
1092
|
+
kind: input.kind,
|
|
1093
|
+
refId: input.refId,
|
|
1094
|
+
raisedBy: participant.participantId,
|
|
1095
|
+
summary: input.summary,
|
|
1096
|
+
positions: input.positions,
|
|
1097
|
+
question: input.question,
|
|
1098
|
+
options: input.options,
|
|
1099
|
+
urgency: input.urgency
|
|
1100
|
+
});
|
|
823
1101
|
this.finish(participant, input.clientRequestId, body, input.usage, escalation);
|
|
824
|
-
this.record(session.sessionId, 'escalation_raised', {
|
|
1102
|
+
this.record(session.sessionId, 'escalation_raised', {
|
|
1103
|
+
escalationId: escalation.escalationId,
|
|
1104
|
+
kind: escalation.kind,
|
|
1105
|
+
refId: escalation.refId,
|
|
1106
|
+
question: escalation.question,
|
|
1107
|
+
urgency: escalation.urgency
|
|
1108
|
+
}, participant.participantId);
|
|
825
1109
|
// Settling with the wrong machine is not a no-op: the review flow throws `Phase nominating cannot advance`
|
|
826
1110
|
// on a scoring session, so the escalation was persisted and the request still failed.
|
|
827
1111
|
if (session.kind === 'scoring')
|
|
@@ -837,12 +1121,36 @@ export class CollabHub {
|
|
|
837
1121
|
return this.scoringDigest(participant);
|
|
838
1122
|
const snapshot = this.snapshot(session.sessionId);
|
|
839
1123
|
const baseline = this.store.getBaselineForRound(session.sessionId, session.round);
|
|
840
|
-
const panel = this.store
|
|
841
|
-
.
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
1124
|
+
const panel = this.store
|
|
1125
|
+
.listParticipants(session.sessionId)
|
|
1126
|
+
.filter(entry => entry.state !== 'left')
|
|
1127
|
+
.map(entry => ({
|
|
1128
|
+
participantId: entry.participantId,
|
|
1129
|
+
role: entry.role,
|
|
1130
|
+
displayName: entry.displayName,
|
|
1131
|
+
model: entry.model,
|
|
1132
|
+
isYou: entry.participantId === participant.participantId
|
|
1133
|
+
}));
|
|
1134
|
+
const base = {
|
|
1135
|
+
sessionId: session.sessionId,
|
|
1136
|
+
kind: session.kind,
|
|
1137
|
+
title: session.title,
|
|
1138
|
+
phase: session.phase,
|
|
1139
|
+
round: session.round,
|
|
1140
|
+
subject: session.subject,
|
|
1141
|
+
baseline: baseline ? { ...baseline, ...this.reviewScope(session, baseline) } : baseline,
|
|
1142
|
+
panel,
|
|
1143
|
+
you: {
|
|
1144
|
+
participantId: participant.participantId,
|
|
1145
|
+
role: participant.role,
|
|
1146
|
+
displayName: participant.displayName,
|
|
1147
|
+
tokensUsed: participant.tokensUsed,
|
|
1148
|
+
tokenBudget: participant.tokenBudget,
|
|
1149
|
+
state: participant.state
|
|
1150
|
+
},
|
|
1151
|
+
progress: sessionProgress(snapshot),
|
|
1152
|
+
stalled: session.stalled
|
|
1153
|
+
};
|
|
846
1154
|
if (session.phase === 'implementing') {
|
|
847
1155
|
if (participant.role !== 'implementer' || !waitingOn(snapshot).includes(participant.participantId))
|
|
848
1156
|
return this.waitDigest(session, base, 'Another implementer is working. Nothing is required from you; end your turn.');
|
|
@@ -850,23 +1158,44 @@ export class CollabHub {
|
|
|
850
1158
|
// than only findings that originally targeted an older implementer seat.
|
|
851
1159
|
const carried = this.store.listIssues(session.sessionId, { status: ['confirmed', 'open'] });
|
|
852
1160
|
const initial = session.round === 1 && session.policy.implementationFirst && !carried.length;
|
|
853
|
-
return {
|
|
1161
|
+
return {
|
|
1162
|
+
...base,
|
|
1163
|
+
task: 'implement',
|
|
1164
|
+
issues: carried.map(issue => this.withHistory(issue)),
|
|
854
1165
|
instructions: initial
|
|
855
1166
|
? `Implement the requested subject in ${session.cwd}. When the code is ready, call collab_submit_ready with a summary, the changed files and a codeRef (commit or dirtyHash), then end your turn. The hub pins the baseline and calls the reviewers.`
|
|
856
|
-
: `Fix the confirmed review findings listed in issues[] in ${session.cwd}. When the code is ready, call collab_submit_ready with a summary, the changed files and a codeRef (commit or dirtyHash), then end your turn. The hub pins the new baseline and calls the reviewers; there is no per-issue response stage.`
|
|
1167
|
+
: `Fix the confirmed review findings listed in issues[] in ${session.cwd}. When the code is ready, call collab_submit_ready with a summary, the changed files and a codeRef (commit or dirtyHash), then end your turn. The hub pins the new baseline and calls the reviewers; there is no per-issue response stage.`
|
|
1168
|
+
};
|
|
857
1169
|
}
|
|
858
1170
|
if (session.phase === 'collecting') {
|
|
859
|
-
const collected = snapshot.completions.some(entry => entry.phase === 'collecting' &&
|
|
1171
|
+
const collected = snapshot.completions.some(entry => entry.phase === 'collecting' &&
|
|
1172
|
+
entry.round === session.round &&
|
|
1173
|
+
entry.participantId === participant.participantId);
|
|
860
1174
|
if (collected)
|
|
861
1175
|
return this.crossVoteDigest(session, participant, snapshot, base, 'You finished the blind review. End your turn; the hub will prompt you if later findings need a vote or the panel moves on.');
|
|
862
|
-
const own = this.store.listIssues(session.sessionId, {
|
|
863
|
-
|
|
1176
|
+
const own = this.store.listIssues(session.sessionId, {
|
|
1177
|
+
reporterId: participant.participantId,
|
|
1178
|
+
round: session.round
|
|
1179
|
+
});
|
|
1180
|
+
const issuesToRecheck = this.store
|
|
1181
|
+
.listIssues(session.sessionId, { status: ['confirmed'], reporterId: participant.participantId })
|
|
1182
|
+
.filter(issue => issue.round < session.round)
|
|
1183
|
+
.map(issue => this.withHistory(issue));
|
|
864
1184
|
const required = participant.role === 'reviewer', reviewerCount = panel.filter(entry => entry.role === 'reviewer').length;
|
|
865
|
-
return {
|
|
866
|
-
|
|
1185
|
+
return {
|
|
1186
|
+
...base,
|
|
1187
|
+
task: required ? 'file_findings' : 'file_findings_optional',
|
|
1188
|
+
yourFindings: own,
|
|
1189
|
+
issuesToRecheck,
|
|
1190
|
+
rules: {
|
|
1191
|
+
...this.consensusRules(session),
|
|
1192
|
+
severity: SEVERITY_GUIDE,
|
|
1193
|
+
requiredAction: REQUIRED_ACTION_GUIDE,
|
|
867
1194
|
blind: `This is a blind review by ${reviewerCount} reviewer(s). You cannot see anyone else's findings yet, and they cannot see yours. Afterwards each of you votes on the others' findings.`,
|
|
868
|
-
scope: 'Review exactly the subject, and when baseline.changedFiles is present, those files. Reporting something outside that scope is what the other reviewers vote down.'
|
|
869
|
-
|
|
1195
|
+
scope: 'Review exactly the subject, and when baseline.changedFiles is present, those files. Reporting something outside that scope is what the other reviewers vote down.'
|
|
1196
|
+
},
|
|
1197
|
+
instructions: `Review the code at the pinned baseline in ${session.cwd} and report every finding in ONE call to collab_submit_findings; that call closes your blind review and ends your turn, so gather everything first. location.path and evidence are mandatory, and evidence must be something you verified in this checkout. ${issuesToRecheck.length ? `Also include one rechecks[] entry (outcome resolved or still_present, with a rationale) for each of the ${issuesToRecheck.length} issuesToRecheck entries. ` : ''}Rate severity and requiredAction by rules.severity and rules.requiredAction, not by how important the finding feels.`
|
|
1198
|
+
};
|
|
870
1199
|
}
|
|
871
1200
|
if (session.phase === 'validating')
|
|
872
1201
|
return this.crossVoteDigest(session, participant, snapshot, base, 'You have voted on every finding that needs your ballot. End your turn; the hub calls you back if later findings need one.');
|
|
@@ -874,8 +1203,13 @@ export class CollabHub {
|
|
|
874
1203
|
const proposals = this.store.listMergeProposals(session.sessionId), owed = proposals.filter(proposal => !proposal.votes.some(vote => vote.participantId === participant.participantId));
|
|
875
1204
|
if (!owed.length)
|
|
876
1205
|
return this.waitDigest(session, base);
|
|
877
|
-
return {
|
|
878
|
-
|
|
1206
|
+
return {
|
|
1207
|
+
...base,
|
|
1208
|
+
task: 'vote_on_merges',
|
|
1209
|
+
mergeProposals: proposals,
|
|
1210
|
+
yourRequiredProposalIds: owed.map(entry => entry.proposalId),
|
|
1211
|
+
instructions: 'Call collab_submit_merge_votes once with a vote on every proposal in yourRequiredProposalIds. A merge is applied only if every reviewer approves it, so reject anything that would fold two genuinely different findings into one; rejecting requires a rationale.'
|
|
1212
|
+
};
|
|
879
1213
|
}
|
|
880
1214
|
if (session.phase === 'issue_discussing') {
|
|
881
1215
|
const consensus = this.annotatedConsensus(session, snapshot), active = new Set(activeContestedIssueIds(snapshot));
|
|
@@ -883,42 +1217,68 @@ export class CollabHub {
|
|
|
883
1217
|
if (!owed.length)
|
|
884
1218
|
return this.waitDigest(session, base);
|
|
885
1219
|
const alone = owed.filter(entry => entry.reporterId === participant.participantId && entry.panelRejected).length;
|
|
886
|
-
return {
|
|
887
|
-
|
|
888
|
-
|
|
1220
|
+
return {
|
|
1221
|
+
...base,
|
|
1222
|
+
task: 'defend_approved_issues',
|
|
1223
|
+
consensus,
|
|
1224
|
+
issues: owed.map(entry => this.withHistory(this.store.getIssue(entry.issueId))),
|
|
1225
|
+
yourRequiredIssueIds: owed.map(entry => entry.issueId),
|
|
1226
|
+
rules: this.consensusRules(session),
|
|
1227
|
+
instructions: `Reviewers rejected the findings in yourRequiredIssueIds and you are a current supporter of each. Call collab_submit_issue_discussions ONCE; it ends your turn, so put everything into that single call. For each finding either answer the rejection with evidence in discussions[], or, if you are its reporter and no longer stand by it, drop it in withdrawals[] with the reason${alone ? ` — ${alone === 1 ? 'one of them is' : `${alone} of them are`} now rejected by every other reviewer, so withdrawing is the expected outcome unless you have new evidence` : ''}. consensus[].positions holds what the rejecters actually argued. If this genuinely needs the human operator, call collab_escalate first: it does not end your turn.`
|
|
1228
|
+
};
|
|
889
1229
|
}
|
|
890
1230
|
if (session.phase === 'issue_reconsidering') {
|
|
891
1231
|
const consensus = this.annotatedConsensus(session, snapshot), active = new Set(activeContestedIssueIds(snapshot));
|
|
892
1232
|
const owed = consensus.filter(entry => active.has(entry.issueId) && entry.rejecters.includes(participant.participantId));
|
|
893
1233
|
if (!owed.length)
|
|
894
1234
|
return this.waitDigest(session, base);
|
|
895
|
-
return {
|
|
896
|
-
|
|
897
|
-
|
|
1235
|
+
return {
|
|
1236
|
+
...base,
|
|
1237
|
+
task: 'reconsider_issue_votes',
|
|
1238
|
+
consensus,
|
|
1239
|
+
issues: owed.map(entry => this.withHistory(this.store.getIssue(entry.issueId))),
|
|
1240
|
+
yourRequiredIssueIds: owed.map(entry => entry.issueId),
|
|
1241
|
+
rules: this.consensusRules(session),
|
|
1242
|
+
instructions: "Read the supporters' latest arguments in issues[].history, then call collab_submit_issue_votes once with a vote for every finding in yourRequiredIssueIds. Switch to approve if the argument answered your objection; keeping reject still requires a rationale that engages with what was argued. A rationale that only repeats your previous one ends the discussion for that finding."
|
|
1243
|
+
};
|
|
898
1244
|
}
|
|
899
1245
|
return this.waitDigest(session, base);
|
|
900
1246
|
}
|
|
901
1247
|
/** The panel's own rules, in the prompt, so a vote is cast against a known standard instead of a guess. */
|
|
902
1248
|
consensusRules(session) {
|
|
903
|
-
return {
|
|
1249
|
+
return {
|
|
1250
|
+
vote: VOTE_GUIDE,
|
|
904
1251
|
contested: `Any reject makes a finding contested: its supporters answer with evidence, then the rejecters vote again, for at most ${session.policy.maxConsensusRounds} round(s).`,
|
|
905
1252
|
outcome: 'Still contested at the end: a finding every other reviewer rejected is dropped, a split panel is decided by majority, and only a blocker/critical dispute reaches the human operator.',
|
|
906
|
-
withdraw: 'The reporter of a finding may withdraw it at any time (collab_withdraw_issue, or withdrawals[] in collab_submit_issue_discussions). Withdrawing something you no longer stand by is a normal, expected move, not a failure.'
|
|
1253
|
+
withdraw: 'The reporter of a finding may withdraw it at any time (collab_withdraw_issue, or withdrawals[] in collab_submit_issue_discussions). Withdrawing something you no longer stand by is a normal, expected move, not a failure.'
|
|
1254
|
+
};
|
|
907
1255
|
}
|
|
908
1256
|
/** Consensus board plus the two facts a defender needs: who filed it, and whether anybody else still agrees. */
|
|
909
1257
|
annotatedConsensus(session, snapshot) {
|
|
910
|
-
const panel = this.store
|
|
1258
|
+
const panel = this.store
|
|
1259
|
+
.listParticipants(session.sessionId)
|
|
1260
|
+
.filter(entry => entry.role === 'reviewer' && entry.state !== 'left');
|
|
911
1261
|
return issueConsensus(snapshot).map(entry => {
|
|
912
1262
|
const issue = this.store.getIssue(entry.issueId), voters = panel.filter(reviewer => reviewer.participantId !== issue.reporterId);
|
|
913
|
-
return {
|
|
914
|
-
|
|
1263
|
+
return {
|
|
1264
|
+
...entry,
|
|
1265
|
+
reporterId: issue.reporterId,
|
|
1266
|
+
severity: issue.severity,
|
|
1267
|
+
title: issue.title,
|
|
1268
|
+
panelRejected: voters.length > 0 && voters.every(reviewer => entry.rejecters.includes(reviewer.participantId))
|
|
1269
|
+
};
|
|
915
1270
|
});
|
|
916
1271
|
}
|
|
917
1272
|
/** A seat with nothing to do must be told exactly that: a stale phase blurb reads as work that was not done. */
|
|
918
1273
|
waitDigest(session, base, reason) {
|
|
919
|
-
return {
|
|
920
|
-
|
|
921
|
-
|
|
1274
|
+
return {
|
|
1275
|
+
...base,
|
|
1276
|
+
task: 'wait',
|
|
1277
|
+
instructions: reason ??
|
|
1278
|
+
(session.phase === 'awaiting_human'
|
|
1279
|
+
? 'A human ruling is pending. Nothing is required from you. Do not resubmit and do not poll; end your turn.'
|
|
1280
|
+
: 'Nothing is required from you right now. Do not submit anything; end your turn. The hub prompts you when something needs you.')
|
|
1281
|
+
};
|
|
922
1282
|
}
|
|
923
1283
|
/**
|
|
924
1284
|
* The concrete file list behind a review subject. Without it every reviewer re-derives the scope from prose
|
|
@@ -926,14 +1286,22 @@ export class CollabHub {
|
|
|
926
1286
|
* disagreement about findings rather than about scope. Derived, never persisted: a function of the pinned commit.
|
|
927
1287
|
*/
|
|
928
1288
|
scopeCache = new Map();
|
|
929
|
-
scopeKey(session, baseline) {
|
|
1289
|
+
scopeKey(session, baseline) {
|
|
1290
|
+
return `${session.sessionId}:${baseline.commit ?? ''}:${baseline.dirtyHash ?? ''}`;
|
|
1291
|
+
}
|
|
930
1292
|
/** Only bare refs/ranges are forwarded, never flags: in the `free` case the value is human-authored prose. */
|
|
931
1293
|
scopeArgs(session) {
|
|
932
|
-
const tokens = session.subject.value
|
|
1294
|
+
const tokens = session.subject.value
|
|
1295
|
+
.split(/\s+/)
|
|
1296
|
+
.filter(token => token && !token.startsWith('-') && !['git', 'diff', 'log', 'show'].includes(token))
|
|
1297
|
+
.slice(0, 2);
|
|
933
1298
|
return ['diff', '--name-only', ...(tokens.length ? tokens : ['HEAD'])];
|
|
934
1299
|
}
|
|
935
1300
|
toScope(stdout) {
|
|
936
|
-
const files = stdout
|
|
1301
|
+
const files = stdout
|
|
1302
|
+
.split('\n')
|
|
1303
|
+
.map(line => line.trim())
|
|
1304
|
+
.filter(Boolean);
|
|
937
1305
|
return { changedFiles: files.slice(0, 200), changedFilesTruncated: files.length > 200 };
|
|
938
1306
|
}
|
|
939
1307
|
reviewScope(session, baseline) {
|
|
@@ -949,7 +1317,13 @@ export class CollabHub {
|
|
|
949
1317
|
// event loop - on every digest, which currentTaskFor now triggers on every dispatch attempt as well.
|
|
950
1318
|
let scope = {};
|
|
951
1319
|
try {
|
|
952
|
-
scope = this.toScope(execFileSync('git', this.scopeArgs(session), {
|
|
1320
|
+
scope = this.toScope(execFileSync('git', this.scopeArgs(session), {
|
|
1321
|
+
cwd: session.cwd,
|
|
1322
|
+
encoding: 'utf8',
|
|
1323
|
+
timeout: 10_000,
|
|
1324
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
1325
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
1326
|
+
}));
|
|
953
1327
|
}
|
|
954
1328
|
catch {
|
|
955
1329
|
scope = {};
|
|
@@ -968,7 +1342,11 @@ export class CollabHub {
|
|
|
968
1342
|
if (this.scopeCache.has(key))
|
|
969
1343
|
return;
|
|
970
1344
|
try {
|
|
971
|
-
this.scopeCache.set(key, this.toScope((await run('git', this.scopeArgs(session), {
|
|
1345
|
+
this.scopeCache.set(key, this.toScope((await run('git', this.scopeArgs(session), {
|
|
1346
|
+
cwd: session.cwd,
|
|
1347
|
+
timeout: 10_000,
|
|
1348
|
+
maxBuffer: 4 * 1024 * 1024
|
|
1349
|
+
})).stdout));
|
|
972
1350
|
}
|
|
973
1351
|
catch {
|
|
974
1352
|
this.scopeCache.set(key, {});
|
|
@@ -976,25 +1354,61 @@ export class CollabHub {
|
|
|
976
1354
|
}
|
|
977
1355
|
reviewSummary(sessionId) {
|
|
978
1356
|
const issues = this.store.listIssues(sessionId), severityOrder = { blocker: 0, critical: 1, major: 2, minor: 3, nit: 4 };
|
|
979
|
-
const entries = issues
|
|
1357
|
+
const entries = issues
|
|
1358
|
+
.map(issue => {
|
|
980
1359
|
const messages = this.store.listIssueMessages(issue.issueId), response = [...messages].reverse().find(message => message.kind === 'response'), verdict = [...messages].reverse().find(message => message.kind === 'verdict');
|
|
981
1360
|
const responseType = typeof response?.payload.responseType === 'string' ? response.payload.responseType : undefined, verdictType = typeof verdict?.payload.verdict === 'string' ? verdict.payload.verdict : undefined;
|
|
982
1361
|
// Historical versions mapped every accepted response to `resolved`. Derive the actionable disposition from
|
|
983
1362
|
// the actual response so a deferred finding never disappears from the close-out report as "fixed".
|
|
984
|
-
const disposition = responseType === 'deferred' && verdictType === 'accept'
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1363
|
+
const disposition = responseType === 'deferred' && verdictType === 'accept'
|
|
1364
|
+
? 'deferred'
|
|
1365
|
+
: responseType === 'rejected' && verdictType === 'accept'
|
|
1366
|
+
? 'wontfix'
|
|
1367
|
+
: responseType === 'needs_info' && verdictType === 'accept'
|
|
1368
|
+
? 'needs_info'
|
|
1369
|
+
: issue.status;
|
|
1370
|
+
const requiresAction = ['open', 'confirmed', 'answered', 'escalated', 'deferred', 'needs_info'].includes(disposition) ||
|
|
1371
|
+
responseType === 'partially_fixed';
|
|
1372
|
+
return {
|
|
1373
|
+
number: issue.number,
|
|
1374
|
+
issueId: issue.issueId,
|
|
1375
|
+
title: issue.title,
|
|
1376
|
+
severity: issue.severity,
|
|
1377
|
+
status: issue.status,
|
|
1378
|
+
disposition,
|
|
1379
|
+
requiresAction,
|
|
1380
|
+
category: issue.category,
|
|
1381
|
+
requiredAction: issue.requiredAction,
|
|
1382
|
+
location: issue.location,
|
|
1383
|
+
description: issue.suggestion || issue.impact || issue.evidence || issue.title,
|
|
1384
|
+
evidence: issue.evidence,
|
|
1385
|
+
impact: issue.impact,
|
|
1386
|
+
suggestion: issue.suggestion,
|
|
1387
|
+
response: response
|
|
1388
|
+
? { responseType, rationale: response.payload.rationale, changes: response.payload.changes ?? [] }
|
|
1389
|
+
: undefined,
|
|
1390
|
+
verdict: verdict ? { verdict: verdictType, rationale: verdict.payload.rationale } : undefined
|
|
1391
|
+
};
|
|
1392
|
+
})
|
|
1393
|
+
.sort((a, b) => (severityOrder[a.severity] ?? 99) - (severityOrder[b.severity] ?? 99) || a.number - b.number);
|
|
990
1394
|
const actionItems = entries.filter(entry => entry.requiresAction), bySeverity = {}, byDisposition = {};
|
|
991
1395
|
for (const entry of entries) {
|
|
992
1396
|
bySeverity[entry.severity] = (bySeverity[entry.severity] ?? 0) + 1;
|
|
993
1397
|
byDisposition[entry.disposition] = (byDisposition[entry.disposition] ?? 0) + 1;
|
|
994
1398
|
}
|
|
995
1399
|
const urgent = actionItems.filter(entry => entry.severity === 'blocker' || entry.severity === 'critical').length;
|
|
996
|
-
return {
|
|
997
|
-
|
|
1400
|
+
return {
|
|
1401
|
+
description: actionItems.length
|
|
1402
|
+
? `${actionItems.length} issue(s) still require implementation or follow-up${urgent ? `, including ${urgent} blocker/critical` : ''}.`
|
|
1403
|
+
: `All ${entries.length} reviewed issue(s) have a terminal disposition and no remaining implementation action.`,
|
|
1404
|
+
verdict: actionItems.length ? (urgent ? 'changes_required' : 'follow_up_required') : 'approved',
|
|
1405
|
+
totalIssues: entries.length,
|
|
1406
|
+
actionItemCount: actionItems.length,
|
|
1407
|
+
bySeverity,
|
|
1408
|
+
byDisposition,
|
|
1409
|
+
actionItems,
|
|
1410
|
+
issues: entries
|
|
1411
|
+
};
|
|
998
1412
|
}
|
|
999
1413
|
reviewConsensus(sessionId, viewer) {
|
|
1000
1414
|
const session = this.store.getSession(sessionId), snapshot = this.snapshot(sessionId);
|
|
@@ -1002,7 +1416,18 @@ export class CollabHub {
|
|
|
1002
1416
|
throw flowError(COLLAB_ERRORS.forbidden, 'Consensus data stays sealed until every reviewer finishes collection', 403);
|
|
1003
1417
|
// The state machine only evaluates still-open issues, but the board is also an audit view after finish.
|
|
1004
1418
|
// Include terminal/resolved/merged issues here so their historical ballots do not disappear at close-out.
|
|
1005
|
-
return {
|
|
1419
|
+
return {
|
|
1420
|
+
phase: session.phase,
|
|
1421
|
+
round: session.round,
|
|
1422
|
+
consensusRound: session.debateRound,
|
|
1423
|
+
issueVotes: this.store.listIssueVotes(sessionId),
|
|
1424
|
+
issueConsensus: issueConsensus(snapshot, { includeFinal: true }),
|
|
1425
|
+
mergeProposals: this.store.listMergeProposals(sessionId),
|
|
1426
|
+
discussions: this.store
|
|
1427
|
+
.listIssues(sessionId)
|
|
1428
|
+
.flatMap(issue => this.store.listIssueMessages(issue.issueId).filter(message => message.kind === 'discussion')),
|
|
1429
|
+
summary: this.reviewSummary(sessionId)
|
|
1430
|
+
};
|
|
1006
1431
|
}
|
|
1007
1432
|
/**
|
|
1008
1433
|
* Per-round audit of a review. The single issue list only ever shows the *current* verdict, so once a recheck
|
|
@@ -1014,12 +1439,32 @@ export class CollabHub {
|
|
|
1014
1439
|
const session = this.store.getSession(sessionId);
|
|
1015
1440
|
if (session.kind !== 'review')
|
|
1016
1441
|
throw flowError(COLLAB_ERRORS.wrongPhase, 'Round history exists for review sessions only');
|
|
1017
|
-
const issues = this.store.listIssues(sessionId), events = this.store.listEventsByType(sessionId, [
|
|
1442
|
+
const issues = this.store.listIssues(sessionId), events = this.store.listEventsByType(sessionId, [
|
|
1443
|
+
'review_reopened',
|
|
1444
|
+
'phase_changed',
|
|
1445
|
+
'session_finished'
|
|
1446
|
+
]);
|
|
1018
1447
|
const history = new Map(issues.map(issue => [issue.issueId, this.store.listIssueMessages(issue.issueId)]));
|
|
1019
|
-
const rechecksOf = (issueId) => (history.get(issueId) ?? [])
|
|
1020
|
-
.
|
|
1021
|
-
|
|
1022
|
-
|
|
1448
|
+
const rechecksOf = (issueId) => (history.get(issueId) ?? [])
|
|
1449
|
+
.filter(message => message.kind === 'recheck')
|
|
1450
|
+
.map(message => ({
|
|
1451
|
+
round: Number(message.payload.reviewRound ?? message.round),
|
|
1452
|
+
outcome: String(message.payload.outcome ?? ''),
|
|
1453
|
+
rationale: String(message.payload.rationale ?? ''),
|
|
1454
|
+
reviewerId: message.authorId,
|
|
1455
|
+
at: message.createdAt
|
|
1456
|
+
}));
|
|
1457
|
+
const brief = (issue) => ({
|
|
1458
|
+
issueId: issue.issueId,
|
|
1459
|
+
number: issue.number,
|
|
1460
|
+
title: issue.title,
|
|
1461
|
+
severity: issue.severity,
|
|
1462
|
+
category: issue.category,
|
|
1463
|
+
status: issue.status,
|
|
1464
|
+
reporterId: issue.reporterId,
|
|
1465
|
+
location: issue.location,
|
|
1466
|
+
foundInRound: issue.round
|
|
1467
|
+
});
|
|
1023
1468
|
// A recheck only ever closes an issue by writing `resolved`. Sessions recorded by the older
|
|
1024
1469
|
// respond/adjudicate flow closed findings with no recheck message at all, so where this round left no
|
|
1025
1470
|
// recheck evidence the current status decides — otherwise every long-closed legacy finding would be
|
|
@@ -1043,22 +1488,46 @@ export class CollabHub {
|
|
|
1043
1488
|
const done = rechecksOf(issue.issueId).find(entry => entry.round === round);
|
|
1044
1489
|
// `pending` is not a reviewer verdict: it is "this wave never reported back on it", which for the live
|
|
1045
1490
|
// round means still owed and for a closed round means it was carried further without an answer.
|
|
1046
|
-
return {
|
|
1491
|
+
return {
|
|
1492
|
+
...brief(issue),
|
|
1493
|
+
outcome: done ? done.outcome : 'pending',
|
|
1494
|
+
rationale: done?.rationale ?? '',
|
|
1495
|
+
reviewerId: done?.reviewerId ?? issue.reporterId,
|
|
1496
|
+
at: done?.at
|
|
1497
|
+
};
|
|
1047
1498
|
});
|
|
1048
|
-
const newIssues = issues
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1499
|
+
const newIssues = issues
|
|
1500
|
+
.filter(issue => issue.round === round)
|
|
1501
|
+
.map(issue => ({
|
|
1502
|
+
...brief(issue),
|
|
1503
|
+
rechecks: rechecksOf(issue.issueId).filter(entry => entry.round > round)
|
|
1504
|
+
}));
|
|
1505
|
+
const finishedEvent = events.find(event => event.type === 'phase_changed' &&
|
|
1506
|
+
event.payload.to === 'finished' &&
|
|
1507
|
+
Number(event.payload.round) === round);
|
|
1508
|
+
const closed = finishedEvent
|
|
1509
|
+
? events.find(event => event.type === 'session_finished' && event.sequence > finishedEvent.sequence)
|
|
1510
|
+
: undefined;
|
|
1052
1511
|
const verdict = closed?.payload.outcome?.verdict;
|
|
1053
|
-
rounds.push({
|
|
1054
|
-
|
|
1512
|
+
rounds.push({
|
|
1513
|
+
round,
|
|
1514
|
+
mode: round === 1 ? 'initial' : String(reopened?.payload.mode ?? 'recheck'),
|
|
1515
|
+
startedAt: round === 1 ? session.createdAt : reopened?.createdAt,
|
|
1516
|
+
finishedAt: finishedEvent?.createdAt,
|
|
1055
1517
|
status: round < session.round || session.status !== 'active' ? 'finished' : 'in_progress',
|
|
1056
|
-
phase: round === session.round ? session.phase : 'finished',
|
|
1518
|
+
phase: round === session.round ? session.phase : 'finished',
|
|
1519
|
+
verdict,
|
|
1057
1520
|
baseline: this.store.getBaselineForRound(sessionId, round),
|
|
1058
|
-
rechecks,
|
|
1059
|
-
|
|
1521
|
+
rechecks,
|
|
1522
|
+
newIssues,
|
|
1523
|
+
counts: {
|
|
1524
|
+
carried: carried.length,
|
|
1525
|
+
resolved: rechecks.filter(entry => entry.outcome === 'resolved').length,
|
|
1060
1526
|
stillPresent: rechecks.filter(entry => entry.outcome === 'still_present').length,
|
|
1061
|
-
pending: rechecks.filter(entry => entry.outcome === 'pending').length,
|
|
1527
|
+
pending: rechecks.filter(entry => entry.outcome === 'pending').length,
|
|
1528
|
+
newIssues: newIssues.length
|
|
1529
|
+
}
|
|
1530
|
+
});
|
|
1062
1531
|
}
|
|
1063
1532
|
return rounds;
|
|
1064
1533
|
}
|
|
@@ -1068,16 +1537,21 @@ export class CollabHub {
|
|
|
1068
1537
|
const issues = this.store.listIssues(session.sessionId);
|
|
1069
1538
|
if (canSeeOthersFindings(snapshot))
|
|
1070
1539
|
return issues;
|
|
1071
|
-
return issues.filter(issue => issue.reporterId === participant.participantId ||
|
|
1540
|
+
return issues.filter(issue => issue.reporterId === participant.participantId ||
|
|
1541
|
+
issue.targetParticipantId === participant.participantId);
|
|
1072
1542
|
}
|
|
1073
1543
|
/**
|
|
1074
1544
|
* Event history. A participant token may read the timeline, but not through it: while a phase is sealed the
|
|
1075
1545
|
* payload of somebody else's submission is withheld, otherwise `blindFindings` could be defeated by
|
|
1076
1546
|
* reading `issue_opened` (title, severity, target) out of the log.
|
|
1077
1547
|
*/
|
|
1078
|
-
events(sessionId, since = 0, limit = 500, viewer) {
|
|
1548
|
+
events(sessionId, since = 0, limit = 500, viewer) {
|
|
1549
|
+
return this.redactEvents(sessionId, this.store.listEvents(sessionId, since, limit), viewer);
|
|
1550
|
+
}
|
|
1079
1551
|
/** Tail of the timeline. The board needs the *latest* events; paging from sequence 0 stops showing new ones. */
|
|
1080
|
-
recentEvents(sessionId, limit = 200, viewer) {
|
|
1552
|
+
recentEvents(sessionId, limit = 200, viewer) {
|
|
1553
|
+
return this.redactEvents(sessionId, this.store.listRecentEvents(sessionId, limit), viewer);
|
|
1554
|
+
}
|
|
1081
1555
|
redactEvents(sessionId, events, viewer) {
|
|
1082
1556
|
if (!viewer)
|
|
1083
1557
|
return events;
|
|
@@ -1101,9 +1575,14 @@ export class CollabHub {
|
|
|
1101
1575
|
return events;
|
|
1102
1576
|
// The event itself stays visible so sequence numbers remain a usable cursor; only its content is withheld.
|
|
1103
1577
|
return events.map(event => sealed.has(event.type) && event.actorId !== viewer.participantId
|
|
1104
|
-
? { ...event, payload: { redacted: true, reason: 'sealed_until_the_phase_closes' } }
|
|
1578
|
+
? { ...event, payload: { redacted: true, reason: 'sealed_until_the_phase_closes' } }
|
|
1579
|
+
: event);
|
|
1580
|
+
}
|
|
1581
|
+
progress(sessionId) {
|
|
1582
|
+
return this.store.getSession(sessionId).kind === 'scoring'
|
|
1583
|
+
? scoringProgress(this.scoringSnapshot(sessionId))
|
|
1584
|
+
: sessionProgress(this.snapshot(sessionId));
|
|
1105
1585
|
}
|
|
1106
|
-
progress(sessionId) { return this.store.getSession(sessionId).kind === 'scoring' ? scoringProgress(this.scoringSnapshot(sessionId)) : sessionProgress(this.snapshot(sessionId)); }
|
|
1107
1586
|
/** Same blindness rule as listIssues(): a sealed finding must not be readable through its id either. */
|
|
1108
1587
|
issueDetail(sessionId, issueId, viewer) {
|
|
1109
1588
|
const issue = this.store.findIssueInSession(sessionId, issueId);
|
|
@@ -1113,7 +1592,9 @@ export class CollabHub {
|
|
|
1113
1592
|
}
|
|
1114
1593
|
/** A participant may read an issue it filed or one addressed to it; everything else waits for the seal to lift. */
|
|
1115
1594
|
canSeeIssue(sessionId, issue, viewer) {
|
|
1116
|
-
return issue.reporterId === viewer.participantId ||
|
|
1595
|
+
return (issue.reporterId === viewer.participantId ||
|
|
1596
|
+
issue.targetParticipantId === viewer.participantId ||
|
|
1597
|
+
canSeeOthersFindings(this.snapshot(sessionId)));
|
|
1117
1598
|
}
|
|
1118
1599
|
// ---------------------------------------------------------------- scoring session operations
|
|
1119
1600
|
/** Blind nomination: a participant only sees its own proposals until the panel finishes. */
|
|
@@ -1126,9 +1607,19 @@ export class CollabHub {
|
|
|
1126
1607
|
assertScoringCapability(this.toFlowParticipant(participant), 'nominate');
|
|
1127
1608
|
assertScoringPhase(snapshot, ['nominating'], 'Nominating criteria');
|
|
1128
1609
|
const accepted = input.nominations.map(nomination => {
|
|
1129
|
-
const criterion = this.store.createCriterion({
|
|
1130
|
-
|
|
1131
|
-
|
|
1610
|
+
const criterion = this.store.createCriterion({
|
|
1611
|
+
sessionId: session.sessionId,
|
|
1612
|
+
name: nomination.name,
|
|
1613
|
+
definition: nomination.definition,
|
|
1614
|
+
anchors: nomination.anchors,
|
|
1615
|
+
weight: nomination.weightSuggestion,
|
|
1616
|
+
source: {
|
|
1617
|
+
participantId: participant.participantId,
|
|
1618
|
+
externalId: nomination.externalId,
|
|
1619
|
+
rationale: nomination.rationale
|
|
1620
|
+
},
|
|
1621
|
+
round: session.round
|
|
1622
|
+
});
|
|
1132
1623
|
return { externalId: nomination.externalId, criterionId: criterion.criterionId, name: criterion.name };
|
|
1133
1624
|
});
|
|
1134
1625
|
if (input.nominationsComplete)
|
|
@@ -1147,7 +1638,10 @@ export class CollabHub {
|
|
|
1147
1638
|
const session = this.store.getSession(participant.sessionId), snapshot = this.scoringSnapshot(session.sessionId);
|
|
1148
1639
|
assertScoringCapability(this.toFlowParticipant(participant), 'vote');
|
|
1149
1640
|
assertScoringPhase(snapshot, ['voting'], 'Voting on criteria');
|
|
1150
|
-
const expected = this.store
|
|
1641
|
+
const expected = this.store
|
|
1642
|
+
.listCriteria(session.sessionId)
|
|
1643
|
+
.filter(criterion => criterion.state === 'candidate')
|
|
1644
|
+
.map(criterion => criterion.criterionId), submitted = input.votes.map(vote => vote.criterionId), voteErrors = [];
|
|
1151
1645
|
for (const criterionId of expected)
|
|
1152
1646
|
if (!submitted.includes(criterionId))
|
|
1153
1647
|
voteErrors.push(fieldError('votes', 'REQUIRED', `A vote for criterion ${criterionId} is required`));
|
|
@@ -1170,10 +1664,23 @@ export class CollabHub {
|
|
|
1170
1664
|
for (const vote of input.votes) {
|
|
1171
1665
|
const criterion = this.store.findCriterionInSession(session.sessionId, vote.criterionId);
|
|
1172
1666
|
if (!criterion || criterion.state !== 'candidate') {
|
|
1173
|
-
rejected.push({
|
|
1667
|
+
rejected.push({
|
|
1668
|
+
criterionId: vote.criterionId,
|
|
1669
|
+
code: COLLAB_ERRORS.criterionNotFound,
|
|
1670
|
+
message: 'Unknown or already decided criterion'
|
|
1671
|
+
});
|
|
1174
1672
|
continue;
|
|
1175
1673
|
}
|
|
1176
|
-
this.store.saveVote({
|
|
1674
|
+
this.store.saveVote({
|
|
1675
|
+
sessionId: session.sessionId,
|
|
1676
|
+
criterionId: vote.criterionId,
|
|
1677
|
+
participantId: participant.participantId,
|
|
1678
|
+
round: session.round,
|
|
1679
|
+
stance: vote.stance,
|
|
1680
|
+
weight: vote.weight,
|
|
1681
|
+
amendment: vote.amendment,
|
|
1682
|
+
rationale: vote.rationale
|
|
1683
|
+
});
|
|
1177
1684
|
accepted.push(vote.criterionId);
|
|
1178
1685
|
}
|
|
1179
1686
|
const response = { accepted, rejected, round: session.round };
|
|
@@ -1192,9 +1699,15 @@ export class CollabHub {
|
|
|
1192
1699
|
assertScoringPhase(snapshot, ['scoring', 'rescoring'], 'Scoring');
|
|
1193
1700
|
await this.assertBaselineCurrent(session);
|
|
1194
1701
|
const { scale } = session.policy.scoring, round = snapshot.phase === 'rescoring' ? session.debateRound + 1 : session.debateRound;
|
|
1195
|
-
const expected =
|
|
1196
|
-
? this.store
|
|
1197
|
-
|
|
1702
|
+
const expected = snapshot.phase === 'rescoring'
|
|
1703
|
+
? this.store
|
|
1704
|
+
.listDebates(session.sessionId)
|
|
1705
|
+
.filter(debate => debate.round === session.debateRound)
|
|
1706
|
+
.map(debate => debate.criterionId)
|
|
1707
|
+
: this.store
|
|
1708
|
+
.listCriteria(session.sessionId)
|
|
1709
|
+
.filter(criterion => criterion.state === 'approved')
|
|
1710
|
+
.map(criterion => criterion.criterionId);
|
|
1198
1711
|
const submitted = input.scores.map(entry => entry.criterionId), errors = [];
|
|
1199
1712
|
for (const criterionId of expected)
|
|
1200
1713
|
if (!submitted.includes(criterionId))
|
|
@@ -1221,10 +1734,24 @@ export class CollabHub {
|
|
|
1221
1734
|
for (const entry of input.scores) {
|
|
1222
1735
|
const criterion = this.store.findCriterionInSession(session.sessionId, entry.criterionId);
|
|
1223
1736
|
if (!criterion || criterion.state !== 'approved') {
|
|
1224
|
-
rejected.push({
|
|
1737
|
+
rejected.push({
|
|
1738
|
+
criterionId: entry.criterionId,
|
|
1739
|
+
code: COLLAB_ERRORS.criterionNotFound,
|
|
1740
|
+
message: 'Criterion is not part of the locked rubric'
|
|
1741
|
+
});
|
|
1225
1742
|
continue;
|
|
1226
1743
|
}
|
|
1227
|
-
this.store.saveScore({
|
|
1744
|
+
this.store.saveScore({
|
|
1745
|
+
sessionId: session.sessionId,
|
|
1746
|
+
criterionId: entry.criterionId,
|
|
1747
|
+
participantId: participant.participantId,
|
|
1748
|
+
round,
|
|
1749
|
+
score: entry.score,
|
|
1750
|
+
rationale: entry.rationale,
|
|
1751
|
+
evidence: entry.evidence,
|
|
1752
|
+
confidence: entry.confidence,
|
|
1753
|
+
changeReason: entry.changeReason
|
|
1754
|
+
});
|
|
1228
1755
|
accepted.push(entry.criterionId);
|
|
1229
1756
|
}
|
|
1230
1757
|
const response = { accepted, rejected, round };
|
|
@@ -1248,7 +1775,13 @@ export class CollabHub {
|
|
|
1248
1775
|
const debate = this.store.findDebateInSession(session.sessionId, debateId);
|
|
1249
1776
|
if (!debate || debate.status !== 'open')
|
|
1250
1777
|
throw flowError(COLLAB_ERRORS.debateNotFound, 'Debate not found or already closed', 404);
|
|
1251
|
-
const argumentId = this.store.addDebateArgument(debateId, {
|
|
1778
|
+
const argumentId = this.store.addDebateArgument(debateId, {
|
|
1779
|
+
participantId: participant.participantId,
|
|
1780
|
+
stance: input.stance,
|
|
1781
|
+
argument: input.argument,
|
|
1782
|
+
evidence: input.evidence,
|
|
1783
|
+
respondingTo: input.respondingTo
|
|
1784
|
+
});
|
|
1252
1785
|
const response = { argumentId, debateId, criterionId: debate.criterionId };
|
|
1253
1786
|
this.finish(participant, input.clientRequestId, body, input.usage, response);
|
|
1254
1787
|
this.record(session.sessionId, 'debate_argument', { debateId, criterionId: debate.criterionId, stance: input.stance }, participant.participantId);
|
|
@@ -1264,9 +1797,15 @@ export class CollabHub {
|
|
|
1264
1797
|
assertScoringCapability(this.toFlowParticipant(participant), 'debate');
|
|
1265
1798
|
assertScoringPhase(snapshot, ['debating'], 'Debating');
|
|
1266
1799
|
await this.assertBaselineCurrent(session);
|
|
1267
|
-
const expected = this.store
|
|
1268
|
-
|
|
1269
|
-
|
|
1800
|
+
const expected = this.store
|
|
1801
|
+
.listDebates(session.sessionId)
|
|
1802
|
+
.filter(debate => debate.status === 'open' &&
|
|
1803
|
+
debate.round === session.debateRound &&
|
|
1804
|
+
this.store
|
|
1805
|
+
.listScores(session.sessionId)
|
|
1806
|
+
.some(score => score.criterionId === debate.criterionId && score.participantId === participant.participantId) &&
|
|
1807
|
+
!debate.arguments.some(argument => argument.participantId === participant.participantId))
|
|
1808
|
+
.map(debate => debate.debateId);
|
|
1270
1809
|
const submitted = input.arguments.map(argument => argument.debateId), errors = [];
|
|
1271
1810
|
for (const debateId of expected)
|
|
1272
1811
|
if (!submitted.includes(debateId))
|
|
@@ -1281,7 +1820,13 @@ export class CollabHub {
|
|
|
1281
1820
|
throw new ValidationError(errors);
|
|
1282
1821
|
const accepted = input.arguments.map(entry => {
|
|
1283
1822
|
const debate = this.store.findDebateInSession(session.sessionId, entry.debateId);
|
|
1284
|
-
const argumentId = this.store.addDebateArgument(entry.debateId, {
|
|
1823
|
+
const argumentId = this.store.addDebateArgument(entry.debateId, {
|
|
1824
|
+
participantId: participant.participantId,
|
|
1825
|
+
stance: entry.stance,
|
|
1826
|
+
argument: entry.argument,
|
|
1827
|
+
evidence: entry.evidence,
|
|
1828
|
+
respondingTo: entry.respondingTo
|
|
1829
|
+
});
|
|
1285
1830
|
this.record(session.sessionId, 'debate_argument', { debateId: entry.debateId, criterionId: debate.criterionId, stance: entry.stance }, participant.participantId);
|
|
1286
1831
|
return { debateId: entry.debateId, argumentId };
|
|
1287
1832
|
});
|
|
@@ -1295,7 +1840,13 @@ export class CollabHub {
|
|
|
1295
1840
|
const snapshot = this.scoringSnapshot(sessionId);
|
|
1296
1841
|
if (viewer && scoresSealed(snapshot))
|
|
1297
1842
|
throw flowError(COLLAB_ERRORS.scoresSealed, 'Scores stay sealed until every panelist has submitted', 403);
|
|
1298
|
-
return {
|
|
1843
|
+
return {
|
|
1844
|
+
phase: snapshot.phase,
|
|
1845
|
+
round: snapshot.round,
|
|
1846
|
+
debateRound: snapshot.debateRound,
|
|
1847
|
+
criteria: analyse(snapshot),
|
|
1848
|
+
contested: contestedCriteria(snapshot).map(entry => entry.criterionId)
|
|
1849
|
+
};
|
|
1299
1850
|
}
|
|
1300
1851
|
criteria(sessionId, viewer) {
|
|
1301
1852
|
const snapshot = this.scoringSnapshot(sessionId), all = this.store.listCriteria(sessionId);
|
|
@@ -1307,7 +1858,9 @@ export class CollabHub {
|
|
|
1307
1858
|
}
|
|
1308
1859
|
votes(sessionId, viewer) {
|
|
1309
1860
|
const snapshot = this.scoringSnapshot(sessionId), all = this.store.listVotes(sessionId);
|
|
1310
|
-
return viewer && votesSealed(snapshot)
|
|
1861
|
+
return viewer && votesSealed(snapshot)
|
|
1862
|
+
? all.filter(vote => vote.participantId === viewer.participantId)
|
|
1863
|
+
: all;
|
|
1311
1864
|
}
|
|
1312
1865
|
/**
|
|
1313
1866
|
* Human ruling on the criteria a panel could not settle; it also closes the session. It is only accepted in
|
|
@@ -1344,7 +1897,11 @@ export class CollabHub {
|
|
|
1344
1897
|
const report = finalizeScores(snapshot, rulings);
|
|
1345
1898
|
// The dispute and the ruling are the same act: leaving the escalation pending would keep the board red forever.
|
|
1346
1899
|
this.settleScoreDispute(sessionId, 'Final scores were set by a human via /finalize', resolvedBy, input.rulings);
|
|
1347
|
-
this.store.updateSession(sessionId, {
|
|
1900
|
+
this.store.updateSession(sessionId, {
|
|
1901
|
+
phase: 'finalized',
|
|
1902
|
+
status: 'finished',
|
|
1903
|
+
outcome: { ...(session.outcome ?? {}), ...report, rulings: input.rulings, resolvedBy }
|
|
1904
|
+
});
|
|
1348
1905
|
this.record(sessionId, 'session_finished', { outcome: report }, resolvedBy);
|
|
1349
1906
|
this.announceFinish(this.store.getSession(sessionId));
|
|
1350
1907
|
return report;
|
|
@@ -1352,13 +1909,49 @@ export class CollabHub {
|
|
|
1352
1909
|
scoringSnapshot(sessionId) {
|
|
1353
1910
|
const session = this.store.getSession(sessionId);
|
|
1354
1911
|
return {
|
|
1355
|
-
phase: session.phase,
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1912
|
+
phase: session.phase,
|
|
1913
|
+
round: session.round,
|
|
1914
|
+
debateRound: session.debateRound,
|
|
1915
|
+
policy: session.policy,
|
|
1916
|
+
status: session.status,
|
|
1917
|
+
participants: this.store
|
|
1918
|
+
.listParticipants(sessionId)
|
|
1919
|
+
.map(participant => this.toFlowParticipant(participant)),
|
|
1920
|
+
criteria: this.store.listCriteria(sessionId).map(criterion => ({
|
|
1921
|
+
criterionId: criterion.criterionId,
|
|
1922
|
+
state: criterion.state,
|
|
1923
|
+
name: criterion.name,
|
|
1924
|
+
round: criterion.round,
|
|
1925
|
+
weight: criterion.weight
|
|
1926
|
+
})),
|
|
1927
|
+
votes: this.store.listVotes(sessionId).map(vote => ({
|
|
1928
|
+
criterionId: vote.criterionId,
|
|
1929
|
+
participantId: vote.participantId,
|
|
1930
|
+
round: vote.round,
|
|
1931
|
+
stance: vote.stance,
|
|
1932
|
+
weight: vote.weight
|
|
1933
|
+
})),
|
|
1934
|
+
scores: this.store.listScores(sessionId).map(score => ({
|
|
1935
|
+
criterionId: score.criterionId,
|
|
1936
|
+
participantId: score.participantId,
|
|
1937
|
+
round: score.round,
|
|
1938
|
+
score: score.score
|
|
1939
|
+
})),
|
|
1940
|
+
debates: this.store.listDebates(sessionId).map(debate => ({
|
|
1941
|
+
debateId: debate.debateId,
|
|
1942
|
+
criterionId: debate.criterionId,
|
|
1943
|
+
round: debate.round,
|
|
1944
|
+
status: debate.status,
|
|
1945
|
+
arguments: debate.arguments.map(entry => ({ participantId: entry.participantId }))
|
|
1946
|
+
})),
|
|
1947
|
+
completions: this.store
|
|
1948
|
+
.listCompletions(sessionId)
|
|
1949
|
+
.filter(entry => entry.phase === 'nominating')
|
|
1950
|
+
.map(entry => ({
|
|
1951
|
+
phase: 'nominating',
|
|
1952
|
+
round: entry.round,
|
|
1953
|
+
participantId: entry.participantId
|
|
1954
|
+
})),
|
|
1362
1955
|
pendingEscalations: this.store.listEscalations({ sessionId, status: 'pending' }).length
|
|
1363
1956
|
};
|
|
1364
1957
|
}
|
|
@@ -1376,28 +1969,56 @@ export class CollabHub {
|
|
|
1376
1969
|
if (!isScoringReadyToAdvance(snapshot) && !(forced && guard === 0))
|
|
1377
1970
|
return;
|
|
1378
1971
|
const result = nextScoringPhase(snapshot, { forced: forced && guard === 0 });
|
|
1379
|
-
if (result.phase === snapshot.phase &&
|
|
1972
|
+
if (result.phase === snapshot.phase &&
|
|
1973
|
+
result.round === snapshot.round &&
|
|
1974
|
+
result.debateRound === snapshot.debateRound)
|
|
1380
1975
|
return;
|
|
1381
1976
|
if (result.rubric)
|
|
1382
1977
|
this.applyRubric(sessionId, result.rubric);
|
|
1383
1978
|
// Leaving awaiting_human always closes the dispute that put us there: a forced advance used to strand a
|
|
1384
1979
|
// score_dispute that no endpoint could resolve afterwards (/finalize refuses a finished session).
|
|
1385
1980
|
if (session.phase === 'awaiting_human' && result.phase !== 'awaiting_human')
|
|
1386
|
-
this.settleScoreDispute(sessionId, forced && guard === 0
|
|
1981
|
+
this.settleScoreDispute(sessionId, forced && guard === 0
|
|
1982
|
+
? `Closed by a forced advance: ${reason || 'no reason given'}`
|
|
1983
|
+
: 'Closed when the panel left awaiting_human');
|
|
1387
1984
|
if (result.phase === 'debating')
|
|
1388
1985
|
for (const criterionId of result.contested ?? [])
|
|
1389
1986
|
this.store.createDebate(sessionId, criterionId, session.debateRound);
|
|
1390
1987
|
if (result.phase === 'rescoring')
|
|
1391
1988
|
this.store.closeDebates(sessionId, session.debateRound);
|
|
1392
1989
|
const finalized = result.phase === 'finalized';
|
|
1393
|
-
const rubricOutcome = result.rubric
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1990
|
+
const rubricOutcome = result.rubric
|
|
1991
|
+
? {
|
|
1992
|
+
...(session.outcome ?? {}),
|
|
1993
|
+
rubricLockedBy: result.rubric.lockedBy,
|
|
1994
|
+
rubricLockReason: result.rubric.reason
|
|
1995
|
+
}
|
|
1996
|
+
: session.outcome;
|
|
1997
|
+
const updated = this.store.updateSession(sessionId, {
|
|
1998
|
+
phase: result.phase,
|
|
1999
|
+
round: result.round,
|
|
2000
|
+
debateRound: result.debateRound,
|
|
2001
|
+
status: finalized ? 'finished' : session.status,
|
|
2002
|
+
stalled: undefined,
|
|
2003
|
+
outcome: finalized
|
|
2004
|
+
? { ...(rubricOutcome ?? {}), ...finalizeScores(this.scoringSnapshot(sessionId)) }
|
|
2005
|
+
: rubricOutcome
|
|
2006
|
+
});
|
|
2007
|
+
this.record(sessionId, 'phase_changed', {
|
|
2008
|
+
from: session.phase,
|
|
2009
|
+
to: result.phase,
|
|
2010
|
+
round: result.round,
|
|
2011
|
+
debateRound: result.debateRound,
|
|
2012
|
+
reason: result.reason,
|
|
2013
|
+
...(forced && guard === 0 ? { forced: true, forceReason: reason, skipped: result.skipped ?? [] } : {}),
|
|
2014
|
+
...(result.contested ? { contested: result.contested } : {})
|
|
2015
|
+
});
|
|
1399
2016
|
if (result.rubric)
|
|
1400
|
-
this.record(sessionId, 'rubric_locked', {
|
|
2017
|
+
this.record(sessionId, 'rubric_locked', {
|
|
2018
|
+
lockedBy: result.rubric.lockedBy,
|
|
2019
|
+
reason: result.rubric.reason,
|
|
2020
|
+
criteria: result.rubric.criteria
|
|
2021
|
+
});
|
|
1401
2022
|
if (finalized) {
|
|
1402
2023
|
this.record(sessionId, 'session_finished', { outcome: updated.outcome ?? {} });
|
|
1403
2024
|
this.announceFinish(this.store.getSession(sessionId));
|
|
@@ -1410,8 +2031,13 @@ export class CollabHub {
|
|
|
1410
2031
|
}
|
|
1411
2032
|
/** Deterministically merges only certain duplicates: normalized names must be identical. Ambiguous semantics stay separate for the panel. */
|
|
1412
2033
|
consolidateCriteria(sessionId) {
|
|
1413
|
-
const candidates = this.store
|
|
1414
|
-
|
|
2034
|
+
const candidates = this.store
|
|
2035
|
+
.listCriteria(sessionId)
|
|
2036
|
+
.filter(criterion => criterion.state === 'candidate'), groups = new Map();
|
|
2037
|
+
const key = (name) => name
|
|
2038
|
+
.normalize('NFKC')
|
|
2039
|
+
.toLowerCase()
|
|
2040
|
+
.replace(/[\s\p{P}\p{S}]+/gu, '');
|
|
1415
2041
|
for (const criterion of candidates) {
|
|
1416
2042
|
const normalized = key(criterion.name);
|
|
1417
2043
|
groups.set(normalized, [...(groups.get(normalized) ?? []), criterion]);
|
|
@@ -1422,11 +2048,21 @@ export class CollabHub {
|
|
|
1422
2048
|
const [primary, ...duplicates] = entries;
|
|
1423
2049
|
const detailed = [...entries].sort((a, b) => b.definition.length - a.definition.length || a.createdAt.localeCompare(b.createdAt))[0];
|
|
1424
2050
|
const anchored = [...entries].sort((a, b) => Object.keys(b.anchors ?? {}).length - Object.keys(a.anchors ?? {}).length)[0];
|
|
1425
|
-
const sources = entries.flatMap(entry => Array.isArray(entry.source?.sources)
|
|
1426
|
-
|
|
2051
|
+
const sources = entries.flatMap(entry => Array.isArray(entry.source?.sources)
|
|
2052
|
+
? entry.source.sources
|
|
2053
|
+
: [entry.source].filter(Boolean));
|
|
2054
|
+
this.store.updateCriterion(primary.criterionId, {
|
|
2055
|
+
definition: detailed.definition,
|
|
2056
|
+
anchors: anchored.anchors,
|
|
2057
|
+
source: { sources, mergedCriterionIds: duplicates.map(entry => entry.criterionId) }
|
|
2058
|
+
});
|
|
1427
2059
|
for (const duplicate of duplicates)
|
|
1428
2060
|
this.store.updateCriterion(duplicate.criterionId, { state: 'rejected' });
|
|
1429
|
-
this.record(sessionId, 'criteria_consolidated', {
|
|
2061
|
+
this.record(sessionId, 'criteria_consolidated', {
|
|
2062
|
+
criterionId: primary.criterionId,
|
|
2063
|
+
mergedCriterionIds: duplicates.map(entry => entry.criterionId),
|
|
2064
|
+
reason: 'identical_normalized_name'
|
|
2065
|
+
});
|
|
1430
2066
|
}
|
|
1431
2067
|
}
|
|
1432
2068
|
applyRubric(sessionId, rubric) {
|
|
@@ -1440,77 +2076,232 @@ export class CollabHub {
|
|
|
1440
2076
|
const pending = this.store.findPendingEscalation(sessionId, 'score_dispute');
|
|
1441
2077
|
if (!pending)
|
|
1442
2078
|
return;
|
|
1443
|
-
this.store.resolveEscalation(pending.escalationId, {
|
|
1444
|
-
|
|
2079
|
+
this.store.resolveEscalation(pending.escalationId, {
|
|
2080
|
+
decision: rulings ? 'scores_ruled' : 'closed_without_ruling',
|
|
2081
|
+
rationale,
|
|
2082
|
+
...(rulings ? { rulings } : {})
|
|
2083
|
+
}, resolvedBy);
|
|
2084
|
+
this.record(sessionId, 'escalation_resolved', {
|
|
2085
|
+
escalationId: pending.escalationId,
|
|
2086
|
+
kind: 'score_dispute',
|
|
2087
|
+
decision: rulings ? 'scores_ruled' : 'closed_without_ruling',
|
|
2088
|
+
rationale
|
|
2089
|
+
}, resolvedBy);
|
|
1445
2090
|
}
|
|
1446
2091
|
raiseScoringEscalation(session, contested) {
|
|
1447
2092
|
if (this.store.findPendingEscalation(session.sessionId, 'score_dispute'))
|
|
1448
2093
|
return;
|
|
1449
2094
|
const names = contested.map(criterionId => this.store.getCriterion(criterionId).name);
|
|
1450
|
-
const escalation = this.store.createEscalation({
|
|
2095
|
+
const escalation = this.store.createEscalation({
|
|
2096
|
+
sessionId: session.sessionId,
|
|
2097
|
+
kind: 'score_dispute',
|
|
2098
|
+
raisedBy: 'system',
|
|
1451
2099
|
summary: `After ${session.policy.scoring.maxDebateRounds} debate round(s) the panel still disagrees on: ${names.join(', ')}.`,
|
|
1452
|
-
positions: this.store
|
|
1453
|
-
.
|
|
1454
|
-
|
|
1455
|
-
|
|
2100
|
+
positions: this.store
|
|
2101
|
+
.listScores(session.sessionId)
|
|
2102
|
+
.filter(score => contested.includes(score.criterionId) && score.round === session.debateRound)
|
|
2103
|
+
.map(score => ({
|
|
2104
|
+
participantId: score.participantId,
|
|
2105
|
+
stance: String(score.score),
|
|
2106
|
+
rationale: score.rationale
|
|
2107
|
+
})),
|
|
2108
|
+
question: 'What is the final score for each contested criterion?',
|
|
2109
|
+
options: [
|
|
2110
|
+
'take the median',
|
|
2111
|
+
'accept the lower score',
|
|
2112
|
+
'accept the higher score',
|
|
2113
|
+
'set a specific score'
|
|
2114
|
+
],
|
|
2115
|
+
urgency: 'normal'
|
|
2116
|
+
});
|
|
2117
|
+
this.record(session.sessionId, 'escalation_raised', {
|
|
2118
|
+
escalationId: escalation.escalationId,
|
|
2119
|
+
kind: 'score_dispute',
|
|
2120
|
+
contested
|
|
2121
|
+
});
|
|
1456
2122
|
}
|
|
1457
2123
|
dispatchScoring(sessionId) {
|
|
1458
2124
|
const session = this.store.getSession(sessionId), snapshot = this.scoringSnapshot(sessionId);
|
|
1459
|
-
const task = {
|
|
2125
|
+
const task = {
|
|
2126
|
+
nominating: 'nominate_criteria',
|
|
2127
|
+
voting: 'vote_on_criteria',
|
|
2128
|
+
scoring: 'score_rubric',
|
|
2129
|
+
debating: 'debate_contested_scores',
|
|
2130
|
+
rescoring: 'rescore_contested'
|
|
2131
|
+
}[session.phase];
|
|
1460
2132
|
if (!task)
|
|
1461
2133
|
return;
|
|
1462
|
-
for (const participantId of scoringWaitingOn(snapshot).length
|
|
1463
|
-
|
|
1464
|
-
|
|
2134
|
+
for (const participantId of scoringWaitingOn(snapshot).length
|
|
2135
|
+
? scoringWaitingOn(snapshot)
|
|
2136
|
+
: scoringPanel(snapshot).map(entry => entry.participantId)) {
|
|
2137
|
+
this.push(sessionId, participantId, task, {
|
|
2138
|
+
phase: session.phase,
|
|
2139
|
+
round: session.round,
|
|
2140
|
+
debateRound: session.debateRound
|
|
2141
|
+
});
|
|
2142
|
+
this.record(sessionId, 'task_assigned', {
|
|
2143
|
+
participantId,
|
|
2144
|
+
task,
|
|
2145
|
+
phase: session.phase,
|
|
2146
|
+
round: session.round
|
|
2147
|
+
});
|
|
1465
2148
|
}
|
|
1466
2149
|
}
|
|
1467
2150
|
scoringDigest(participant) {
|
|
1468
2151
|
const session = this.store.getSession(participant.sessionId), snapshot = this.scoringSnapshot(session.sessionId), criteria = this.store.listCriteria(session.sessionId), votes = this.store.listVotes(session.sessionId), scores = this.store.listScores(session.sessionId), debates = this.store.listDebates(session.sessionId);
|
|
1469
|
-
const byId = (ids) => ids
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
2152
|
+
const byId = (ids) => ids
|
|
2153
|
+
.map(id => criteria.find(criterion => criterion.criterionId === id))
|
|
2154
|
+
.filter((entry) => !!entry);
|
|
2155
|
+
const base = {
|
|
2156
|
+
sessionId: session.sessionId,
|
|
2157
|
+
kind: session.kind,
|
|
2158
|
+
title: session.title,
|
|
2159
|
+
phase: session.phase,
|
|
2160
|
+
round: session.round,
|
|
2161
|
+
debateRound: session.debateRound,
|
|
2162
|
+
subject: session.subject,
|
|
2163
|
+
baseline: this.store.getBaselineForRound(session.sessionId, 1),
|
|
2164
|
+
scoringPolicy: session.policy.scoring,
|
|
2165
|
+
you: {
|
|
2166
|
+
participantId: participant.participantId,
|
|
2167
|
+
role: participant.role,
|
|
2168
|
+
displayName: participant.displayName,
|
|
2169
|
+
tokensUsed: participant.tokensUsed,
|
|
2170
|
+
tokenBudget: participant.tokenBudget,
|
|
2171
|
+
state: participant.state
|
|
2172
|
+
},
|
|
2173
|
+
progress: scoringProgress(snapshot),
|
|
2174
|
+
stalled: session.stalled
|
|
2175
|
+
};
|
|
1474
2176
|
if (session.phase === 'nominating') {
|
|
1475
2177
|
const previousRound = session.round > 1 ? session.round - 1 : undefined;
|
|
1476
|
-
return {
|
|
2178
|
+
return {
|
|
2179
|
+
...base,
|
|
2180
|
+
task: 'nominate_criteria',
|
|
2181
|
+
yourNominations: criteria.filter(criterion => criterion.round === session.round &&
|
|
2182
|
+
criterion.source?.participantId === participant.participantId),
|
|
1477
2183
|
existingCandidates: criteria.filter(criterion => criterion.state === 'candidate' && criterion.round < session.round),
|
|
1478
|
-
previousRound: previousRound
|
|
1479
|
-
|
|
2184
|
+
previousRound: previousRound
|
|
2185
|
+
? {
|
|
2186
|
+
round: previousRound,
|
|
2187
|
+
tallies: tallyVotes(snapshot, previousRound),
|
|
2188
|
+
amendments: votes.filter(vote => vote.round === previousRound && vote.amendment)
|
|
2189
|
+
}
|
|
2190
|
+
: undefined,
|
|
2191
|
+
instructions: `Propose enough distinct scoring criteria to help the panel reach ${session.policy.scoring.minCriteria}-${session.policy.scoring.maxCriteria} dimensions. Include a definition and concrete score anchors. Your current-round proposals stay blind until every reviewer finishes.`
|
|
2192
|
+
};
|
|
1480
2193
|
}
|
|
1481
2194
|
if (session.phase === 'voting') {
|
|
1482
|
-
const candidates = criteria.filter(criterion => criterion.state === 'candidate'), required = candidates
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
2195
|
+
const candidates = criteria.filter(criterion => criterion.state === 'candidate'), required = candidates
|
|
2196
|
+
.filter(criterion => !votes.some(vote => vote.criterionId === criterion.criterionId &&
|
|
2197
|
+
vote.participantId === participant.participantId &&
|
|
2198
|
+
vote.round === session.round))
|
|
2199
|
+
.map(criterion => criterion.criterionId), previousRound = session.round > 1 ? session.round - 1 : undefined;
|
|
2200
|
+
return {
|
|
2201
|
+
...base,
|
|
2202
|
+
task: 'vote_on_criteria',
|
|
2203
|
+
candidates,
|
|
2204
|
+
yourRequiredCriterionIds: required,
|
|
2205
|
+
previousRound: previousRound
|
|
2206
|
+
? {
|
|
2207
|
+
round: previousRound,
|
|
2208
|
+
tallies: tallyVotes(snapshot, previousRound),
|
|
2209
|
+
amendments: votes.filter(vote => vote.round === previousRound && vote.amendment)
|
|
2210
|
+
}
|
|
2211
|
+
: undefined,
|
|
2212
|
+
instructions: `Vote approve, reject, or abstain on every required candidate. The rubric needs ${session.policy.scoring.minCriteria}-${session.policy.scoring.maxCriteria} dimensions. Suggest a weight for approvals; rejecting requires a rationale.`
|
|
2213
|
+
};
|
|
1486
2214
|
}
|
|
1487
2215
|
if (session.phase === 'scoring') {
|
|
1488
|
-
const rubric = criteria.filter(criterion => criterion.state === 'approved'), required = rubric
|
|
1489
|
-
|
|
1490
|
-
|
|
2216
|
+
const rubric = criteria.filter(criterion => criterion.state === 'approved'), required = rubric
|
|
2217
|
+
.filter(criterion => !scores.some(score => score.criterionId === criterion.criterionId &&
|
|
2218
|
+
score.participantId === participant.participantId &&
|
|
2219
|
+
score.round === session.debateRound))
|
|
2220
|
+
.map(criterion => criterion.criterionId);
|
|
2221
|
+
return {
|
|
2222
|
+
...base,
|
|
2223
|
+
task: 'score_rubric',
|
|
2224
|
+
rubric,
|
|
2225
|
+
yourRequiredCriterionIds: required,
|
|
2226
|
+
instructions: `Score every required criterion on the ${session.policy.scoring.scale.min}-${session.policy.scoring.scale.max} scale in increments of ${session.policy.scoring.scale.step}. Apply its definition and anchors independently. Every score needs a rationale and file evidence.`
|
|
2227
|
+
};
|
|
1491
2228
|
}
|
|
1492
2229
|
if (session.phase === 'debating') {
|
|
1493
|
-
const open = debates.filter(debate => debate.status === 'open' && debate.round === session.debateRound), required = open
|
|
1494
|
-
|
|
1495
|
-
|
|
2230
|
+
const open = debates.filter(debate => debate.status === 'open' && debate.round === session.debateRound), required = open
|
|
2231
|
+
.filter(debate => scores.some(score => score.criterionId === debate.criterionId &&
|
|
2232
|
+
score.participantId === participant.participantId) && !debate.arguments.some(argument => argument.participantId === participant.participantId))
|
|
2233
|
+
.map(debate => debate.debateId);
|
|
2234
|
+
return {
|
|
2235
|
+
...base,
|
|
2236
|
+
task: 'debate_contested_scores',
|
|
2237
|
+
debates: open.map(debate => ({
|
|
2238
|
+
...debate,
|
|
2239
|
+
criterion: criteria.find(entry => entry.criterionId === debate.criterionId)
|
|
2240
|
+
})),
|
|
2241
|
+
yourRequiredDebateIds: required,
|
|
2242
|
+
analysis: analyse(snapshot),
|
|
2243
|
+
scoreDetails: scores.filter(score => open.some(debate => debate.criterionId === score.criterionId) &&
|
|
2244
|
+
score.round === session.debateRound),
|
|
2245
|
+
instructions: 'Address every required debate using the criterion definition, score evidence, and other panelists arguments. Use hold if you keep your score, raise/lower if the evidence changes your position.'
|
|
2246
|
+
};
|
|
1496
2247
|
}
|
|
1497
2248
|
if (session.phase === 'rescoring') {
|
|
1498
|
-
const contested = [
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
2249
|
+
const contested = [
|
|
2250
|
+
...new Set(debates.filter(debate => debate.round === session.debateRound).map(debate => debate.criterionId))
|
|
2251
|
+
], nextRound = session.debateRound + 1, required = contested.filter(criterionId => !scores.some(score => score.criterionId === criterionId &&
|
|
2252
|
+
score.participantId === participant.participantId &&
|
|
2253
|
+
score.round === nextRound));
|
|
2254
|
+
return {
|
|
2255
|
+
...base,
|
|
2256
|
+
task: 'rescore_contested',
|
|
2257
|
+
rubric: byId(contested),
|
|
2258
|
+
yourRequiredCriterionIds: required,
|
|
2259
|
+
analysis: analyse(snapshot),
|
|
2260
|
+
debates: debates
|
|
2261
|
+
.filter(debate => debate.round === session.debateRound)
|
|
2262
|
+
.map(debate => ({
|
|
2263
|
+
...debate,
|
|
2264
|
+
criterion: criteria.find(entry => entry.criterionId === debate.criterionId)
|
|
2265
|
+
})),
|
|
2266
|
+
scoreDetails: scores.filter(score => contested.includes(score.criterionId) && score.round <= session.debateRound),
|
|
2267
|
+
instructions: `Rescore every required contested criterion on the ${session.policy.scoring.scale.min}-${session.policy.scoring.scale.max} scale after reading the debate. Evidence and rationale remain mandatory; changeReason must explain both a change and an explicit hold.`
|
|
2268
|
+
};
|
|
1502
2269
|
}
|
|
1503
|
-
return {
|
|
2270
|
+
return {
|
|
2271
|
+
...base,
|
|
2272
|
+
task: 'wait',
|
|
2273
|
+
instructions: session.phase === 'awaiting_human'
|
|
2274
|
+
? 'A human is ruling on the contested criteria. End your turn.'
|
|
2275
|
+
: 'Nothing is required from you right now. End your turn.'
|
|
2276
|
+
};
|
|
1504
2277
|
}
|
|
1505
2278
|
// ---------------------------------------------------------------- internals
|
|
1506
2279
|
snapshot(sessionId) {
|
|
1507
2280
|
const session = this.store.getSession(sessionId);
|
|
1508
2281
|
return {
|
|
1509
|
-
phase: session.phase,
|
|
1510
|
-
|
|
2282
|
+
phase: session.phase,
|
|
2283
|
+
round: session.round,
|
|
2284
|
+
debateRound: session.debateRound,
|
|
2285
|
+
policy: session.policy,
|
|
2286
|
+
status: session.status,
|
|
2287
|
+
participants: this.store
|
|
2288
|
+
.listParticipants(sessionId)
|
|
2289
|
+
.map(participant => this.toFlowParticipant(participant)),
|
|
1511
2290
|
issues: this.store.listIssues(sessionId).map(issue => this.toFlowIssue(issue)),
|
|
1512
|
-
issueVotes: this.store.listIssueVotes(sessionId),
|
|
1513
|
-
|
|
2291
|
+
issueVotes: this.store.listIssueVotes(sessionId),
|
|
2292
|
+
mergeProposals: this.store.listMergeProposals(sessionId),
|
|
2293
|
+
completions: this.store
|
|
2294
|
+
.listCompletions(sessionId)
|
|
2295
|
+
.filter(entry => {
|
|
2296
|
+
const [phase, suffix] = entry.phase.split(':');
|
|
2297
|
+
return (!['issue_discussing', 'issue_reconsidering'].includes(phase) ||
|
|
2298
|
+
Number(suffix) === session.debateRound);
|
|
2299
|
+
})
|
|
2300
|
+
.map(entry => ({
|
|
2301
|
+
phase: entry.phase.split(':')[0],
|
|
2302
|
+
round: entry.round,
|
|
2303
|
+
participantId: entry.participantId
|
|
2304
|
+
})),
|
|
1514
2305
|
pendingEscalations: this.store.listEscalations({ sessionId, status: 'pending' }).length
|
|
1515
2306
|
};
|
|
1516
2307
|
}
|
|
@@ -1527,14 +2318,23 @@ export class CollabHub {
|
|
|
1527
2318
|
async applyPhase(session, result, context) {
|
|
1528
2319
|
const finished = result.phase === 'finished';
|
|
1529
2320
|
if (context.forced && result.skipped?.length) {
|
|
1530
|
-
const completionPhase = ['issue_discussing', 'issue_reconsidering'].includes(session.phase)
|
|
2321
|
+
const completionPhase = ['issue_discussing', 'issue_reconsidering'].includes(session.phase)
|
|
2322
|
+
? `${session.phase}:${session.debateRound}`
|
|
2323
|
+
: session.phase;
|
|
1531
2324
|
for (const participantId of result.skipped)
|
|
1532
2325
|
this.store.markPhaseComplete(session.sessionId, session.round, completionPhase, participantId);
|
|
1533
2326
|
if (session.phase === 'merge_voting')
|
|
1534
|
-
for (const proposal of this.store
|
|
2327
|
+
for (const proposal of this.store
|
|
2328
|
+
.listMergeProposals(session.sessionId)
|
|
2329
|
+
.filter(entry => entry.round === session.round))
|
|
1535
2330
|
for (const participantId of result.skipped)
|
|
1536
2331
|
if (!proposal.votes.some(vote => vote.participantId === participantId))
|
|
1537
|
-
this.store.saveMergeVote({
|
|
2332
|
+
this.store.saveMergeVote({
|
|
2333
|
+
proposalId: proposal.proposalId,
|
|
2334
|
+
participantId,
|
|
2335
|
+
stance: 'reject',
|
|
2336
|
+
rationale: `Skipped by forced advance: ${context.reason || 'no reason given'}`
|
|
2337
|
+
});
|
|
1538
2338
|
}
|
|
1539
2339
|
if (session.phase === 'merge_voting' && result.phase === 'consolidating')
|
|
1540
2340
|
this.applyApprovedMerges(session.sessionId);
|
|
@@ -1551,10 +2351,29 @@ export class CollabHub {
|
|
|
1551
2351
|
this.store.updateIssue(issue.issueId, { status: 'confirmed' });
|
|
1552
2352
|
this.record(session.sessionId, 'issue_confirmed', { issueId: issue.issueId, round: result.round });
|
|
1553
2353
|
}
|
|
1554
|
-
const updated = this.store.updateSession(session.sessionId, {
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
2354
|
+
const updated = this.store.updateSession(session.sessionId, {
|
|
2355
|
+
phase: result.phase,
|
|
2356
|
+
round: result.round,
|
|
2357
|
+
debateRound: result.debateRound ?? session.debateRound,
|
|
2358
|
+
status: finished ? 'finished' : session.status,
|
|
2359
|
+
stalled: undefined,
|
|
2360
|
+
outcome: finished ? this.outcome(session.sessionId) : session.outcome
|
|
2361
|
+
});
|
|
2362
|
+
this.record(session.sessionId, 'phase_changed', {
|
|
2363
|
+
from: session.phase,
|
|
2364
|
+
to: result.phase,
|
|
2365
|
+
round: result.round,
|
|
2366
|
+
debateRound: result.debateRound ?? session.debateRound,
|
|
2367
|
+
reason: result.reason,
|
|
2368
|
+
...(context.forced
|
|
2369
|
+
? {
|
|
2370
|
+
forced: true,
|
|
2371
|
+
forcedBy: context.actor ?? 'human',
|
|
2372
|
+
forceReason: context.reason,
|
|
2373
|
+
skipped: result.skipped ?? []
|
|
2374
|
+
}
|
|
2375
|
+
: {})
|
|
2376
|
+
}, context.forced ? (context.actor ?? 'human') : undefined);
|
|
1558
2377
|
if (result.escalateDeadlock)
|
|
1559
2378
|
this.raiseDeadlockEscalation(updated);
|
|
1560
2379
|
if (finished) {
|
|
@@ -1565,8 +2384,16 @@ export class CollabHub {
|
|
|
1565
2384
|
this.dispatch(session.sessionId);
|
|
1566
2385
|
}
|
|
1567
2386
|
async captureBaseline(session) {
|
|
1568
|
-
const snapshot = await this.resolveBaseline({
|
|
1569
|
-
|
|
2387
|
+
const snapshot = await this.resolveBaseline({
|
|
2388
|
+
cwd: session.cwd,
|
|
2389
|
+
subject: session.subject,
|
|
2390
|
+
round: session.round
|
|
2391
|
+
});
|
|
2392
|
+
const baseline = this.store.saveBaseline({
|
|
2393
|
+
sessionId: session.sessionId,
|
|
2394
|
+
round: session.round,
|
|
2395
|
+
...snapshot
|
|
2396
|
+
});
|
|
1570
2397
|
this.record(session.sessionId, 'baseline_captured', { ...baseline });
|
|
1571
2398
|
await this.warmReviewScope(session, baseline);
|
|
1572
2399
|
return baseline;
|
|
@@ -1575,8 +2402,15 @@ export class CollabHub {
|
|
|
1575
2402
|
async assertBaselineCurrent(session, baseline = this.store.getBaselineForRound(session.sessionId, session.kind === 'scoring' ? 1 : session.round)) {
|
|
1576
2403
|
if (!baseline || baseline.vcs === 'none')
|
|
1577
2404
|
return;
|
|
1578
|
-
const current = await this.resolveBaseline({
|
|
1579
|
-
|
|
2405
|
+
const current = await this.resolveBaseline({
|
|
2406
|
+
cwd: session.cwd,
|
|
2407
|
+
subject: session.subject,
|
|
2408
|
+
round: baseline.round
|
|
2409
|
+
});
|
|
2410
|
+
if (current.vcs !== baseline.vcs ||
|
|
2411
|
+
current.commit !== baseline.commit ||
|
|
2412
|
+
current.dirtyHash !== baseline.dirtyHash ||
|
|
2413
|
+
(baseline.rangeResolved !== undefined && current.rangeResolved !== baseline.rangeResolved))
|
|
1580
2414
|
throw Object.assign(flowError(COLLAB_ERRORS.staleBaseline, 'The workspace changed after this collaboration baseline was captured. Refresh or restart the review against a new baseline.'), { currentBaseline: { ...current, round: baseline.round } });
|
|
1581
2415
|
}
|
|
1582
2416
|
/**
|
|
@@ -1589,12 +2423,20 @@ export class CollabHub {
|
|
|
1589
2423
|
const owed = owedIssueVoteIds(snapshot, participant.participantId);
|
|
1590
2424
|
if (!owed.length)
|
|
1591
2425
|
return this.waitDigest(session, base, idle);
|
|
1592
|
-
return {
|
|
1593
|
-
|
|
2426
|
+
return {
|
|
2427
|
+
...base,
|
|
2428
|
+
task: 'validate_issues',
|
|
2429
|
+
issues: this.store
|
|
2430
|
+
.listIssues(session.sessionId, { status: ['open'], round: session.round })
|
|
2431
|
+
.map(issue => this.withHistory(issue)),
|
|
2432
|
+
yourRequiredIssueIds: owed,
|
|
2433
|
+
currentVotes: currentIssueVotes(snapshot),
|
|
2434
|
+
mergeProposals: this.store.listMergeProposals(session.sessionId),
|
|
1594
2435
|
rules: { ...this.consensusRules(session), severity: SEVERITY_GUIDE },
|
|
1595
2436
|
instructions: session.phase === 'collecting'
|
|
1596
2437
|
? 'You finished your blind review; other reviewers are still filing. Verify each finding in yourRequiredIssueIds against the code yourself, then call collab_submit_issue_votes once with a vote for every one of them. Voting on the set you were given is enough: the hub calls you again for findings that arrive later. A reject needs a rationale. Do not wait, sleep or poll. Merge proposals wait until every reviewer has filed.'
|
|
1597
|
-
: 'Every reviewer has finished the blind review. Verify each finding in yourRequiredIssueIds against the code yourself - approve and reject are claims about the code, not impressions - then call collab_submit_issue_votes once with a vote for each. A reject needs a rationale of at least 20 characters. Put findings that are genuinely the same defect into mergeProposals. Fixing is a separate, human-triggered cycle; this review ends at consensus.'
|
|
2438
|
+
: 'Every reviewer has finished the blind review. Verify each finding in yourRequiredIssueIds against the code yourself - approve and reject are claims about the code, not impressions - then call collab_submit_issue_votes once with a vote for each. A reject needs a rationale of at least 20 characters. Put findings that are genuinely the same defect into mergeProposals. Fixing is a separate, human-triggered cycle; this review ends at consensus.'
|
|
2439
|
+
};
|
|
1598
2440
|
}
|
|
1599
2441
|
/**
|
|
1600
2442
|
* Cross-votes should not wait for the slowest collector. As soon as two reviewers have closed their own
|
|
@@ -1602,17 +2444,32 @@ export class CollabHub {
|
|
|
1602
2444
|
*/
|
|
1603
2445
|
dispatchEarlyIssueVotes(sessionId) {
|
|
1604
2446
|
const session = this.store.findSession(sessionId);
|
|
1605
|
-
if (!session ||
|
|
2447
|
+
if (!session ||
|
|
2448
|
+
session.kind !== 'review' ||
|
|
2449
|
+
session.phase !== 'collecting' ||
|
|
2450
|
+
session.status !== 'active' ||
|
|
2451
|
+
!session.policy.consensusReview)
|
|
1606
2452
|
return [];
|
|
1607
2453
|
const snapshot = this.snapshot(sessionId), assigned = [];
|
|
1608
2454
|
for (const participantId of pendingCrossVoters(snapshot)) {
|
|
1609
2455
|
const owed = owedIssueVoteIds(snapshot, participantId), task = `validate_issues#${owed.length}`;
|
|
1610
2456
|
// Findings arrive one wave at a time, so the owed count grows: dedup on the prefix or the same seat
|
|
1611
2457
|
// collects validate_issues#2, #5, #9 as separate durable items and separate wake-ups.
|
|
1612
|
-
if (!owed.length ||
|
|
2458
|
+
if (!owed.length ||
|
|
2459
|
+
this.store.listInbox(participantId).some(item => item.type.split('#')[0] === 'validate_issues'))
|
|
1613
2460
|
continue;
|
|
1614
|
-
this.push(sessionId, participantId, task, {
|
|
1615
|
-
|
|
2461
|
+
this.push(sessionId, participantId, task, {
|
|
2462
|
+
phase: session.phase,
|
|
2463
|
+
round: session.round,
|
|
2464
|
+
owed: owed.length
|
|
2465
|
+
});
|
|
2466
|
+
this.record(sessionId, 'task_assigned', {
|
|
2467
|
+
participantId,
|
|
2468
|
+
task,
|
|
2469
|
+
phase: session.phase,
|
|
2470
|
+
round: session.round,
|
|
2471
|
+
early: true
|
|
2472
|
+
});
|
|
1616
2473
|
assigned.push(participantId);
|
|
1617
2474
|
}
|
|
1618
2475
|
return assigned;
|
|
@@ -1624,7 +2481,9 @@ export class CollabHub {
|
|
|
1624
2481
|
}
|
|
1625
2482
|
/** Queues the task for every participant that now owes work. Managed agents are woken from this queue. */
|
|
1626
2483
|
dispatch(sessionId) {
|
|
1627
|
-
const session = this.store.getSession(sessionId), participants = this.store
|
|
2484
|
+
const session = this.store.getSession(sessionId), participants = this.store
|
|
2485
|
+
.listParticipants(sessionId)
|
|
2486
|
+
.filter(participant => participant.state === 'active');
|
|
1628
2487
|
const targets = new Map();
|
|
1629
2488
|
if (session.phase === 'implementing')
|
|
1630
2489
|
for (const participantId of waitingOn(this.snapshot(sessionId)))
|
|
@@ -1632,7 +2491,8 @@ export class CollabHub {
|
|
|
1632
2491
|
// In build-then-review the implementer is writing code, not filing reverse findings, so it gets no task here.
|
|
1633
2492
|
if (session.phase === 'collecting')
|
|
1634
2493
|
for (const participant of participants)
|
|
1635
|
-
if (participant.role !== 'moderator' &&
|
|
2494
|
+
if (participant.role !== 'moderator' &&
|
|
2495
|
+
!(session.policy.implementationFirst && participant.role === 'implementer'))
|
|
1636
2496
|
targets.set(participant.participantId, participant.role === 'reviewer' ? 'file_findings' : 'file_findings_optional');
|
|
1637
2497
|
if (session.phase === 'validating')
|
|
1638
2498
|
for (const participantId of waitingOn(this.snapshot(sessionId)))
|
|
@@ -1648,21 +2508,44 @@ export class CollabHub {
|
|
|
1648
2508
|
targets.set(participantId, 'reconsider_issue_votes');
|
|
1649
2509
|
for (const [participantId, task] of targets) {
|
|
1650
2510
|
this.push(sessionId, participantId, task, { phase: session.phase, round: session.round });
|
|
1651
|
-
this.record(sessionId, 'task_assigned', {
|
|
2511
|
+
this.record(sessionId, 'task_assigned', {
|
|
2512
|
+
participantId,
|
|
2513
|
+
task,
|
|
2514
|
+
phase: session.phase,
|
|
2515
|
+
round: session.round
|
|
2516
|
+
});
|
|
1652
2517
|
}
|
|
1653
2518
|
}
|
|
1654
2519
|
applyApprovedMerges(sessionId) {
|
|
1655
|
-
const panel = this.store
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
2520
|
+
const panel = this.store
|
|
2521
|
+
.listParticipants(sessionId)
|
|
2522
|
+
.filter(participant => participant.role === 'reviewer' && participant.state !== 'left'), session = this.store.getSession(sessionId), proposals = this.store
|
|
2523
|
+
.listMergeProposals(sessionId)
|
|
2524
|
+
.filter(proposal => proposal.round === session.round), parent = new Map();
|
|
2525
|
+
const root = (id) => {
|
|
2526
|
+
const value = parent.get(id);
|
|
2527
|
+
if (!value) {
|
|
2528
|
+
parent.set(id, id);
|
|
2529
|
+
return id;
|
|
2530
|
+
}
|
|
2531
|
+
if (value === id)
|
|
2532
|
+
return id;
|
|
2533
|
+
const resolved = root(value);
|
|
2534
|
+
parent.set(id, resolved);
|
|
2535
|
+
return resolved;
|
|
2536
|
+
};
|
|
2537
|
+
const join = (a, b) => {
|
|
2538
|
+
const left = root(a), right = root(b);
|
|
2539
|
+
if (left !== right)
|
|
2540
|
+
parent.set(right, left);
|
|
2541
|
+
};
|
|
1663
2542
|
for (const proposal of proposals) {
|
|
1664
2543
|
const approved = panel.every(reviewer => proposal.votes.some(vote => vote.participantId === reviewer.participantId && vote.stance === 'approve'));
|
|
1665
|
-
this.record(sessionId, approved ? 'merge_proposal_approved' : 'merge_proposal_rejected', {
|
|
2544
|
+
this.record(sessionId, approved ? 'merge_proposal_approved' : 'merge_proposal_rejected', {
|
|
2545
|
+
proposalId: proposal.proposalId,
|
|
2546
|
+
issueIds: proposal.issueIds,
|
|
2547
|
+
votes: proposal.votes
|
|
2548
|
+
});
|
|
1666
2549
|
if (approved)
|
|
1667
2550
|
for (const issueId of proposal.issueIds.slice(1))
|
|
1668
2551
|
join(proposal.issueIds[0], issueId);
|
|
@@ -1673,12 +2556,17 @@ export class CollabHub {
|
|
|
1673
2556
|
groups.set(key, [...(groups.get(key) ?? []), issueId]);
|
|
1674
2557
|
}
|
|
1675
2558
|
for (const ids of groups.values()) {
|
|
1676
|
-
const issues = ids
|
|
2559
|
+
const issues = ids
|
|
2560
|
+
.map(id => this.store.getIssue(id))
|
|
2561
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.issueId.localeCompare(b.issueId)), primary = issues[0];
|
|
1677
2562
|
for (const duplicate of issues.slice(1)) {
|
|
1678
2563
|
if (duplicate.status !== 'open')
|
|
1679
2564
|
continue;
|
|
1680
2565
|
this.store.updateIssue(duplicate.issueId, { status: 'duplicate', mergedInto: primary.issueId });
|
|
1681
|
-
this.record(sessionId, 'issues_merged', {
|
|
2566
|
+
this.record(sessionId, 'issues_merged', {
|
|
2567
|
+
primaryIssueId: primary.issueId,
|
|
2568
|
+
duplicateIssueId: duplicate.issueId
|
|
2569
|
+
});
|
|
1682
2570
|
}
|
|
1683
2571
|
}
|
|
1684
2572
|
this.store.markPhaseComplete(sessionId, session.round, 'merge_voting', 'system');
|
|
@@ -1696,37 +2584,95 @@ export class CollabHub {
|
|
|
1696
2584
|
if (resolution.disposition !== 'escalate') {
|
|
1697
2585
|
const status = resolution.disposition === 'majority_confirmed' ? 'confirmed' : 'wontfix';
|
|
1698
2586
|
this.store.updateIssue(issue.issueId, { status });
|
|
1699
|
-
this.store.addIssueMessage(issue.issueId, session.round, 'system', 'ruling', {
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
2587
|
+
this.store.addIssueMessage(issue.issueId, session.round, 'system', 'ruling', {
|
|
2588
|
+
decision: resolution.disposition,
|
|
2589
|
+
rationale: resolution.reason,
|
|
2590
|
+
supporters: resolution.supporters,
|
|
2591
|
+
rejecters: resolution.rejecters,
|
|
2592
|
+
consensusRound: session.debateRound
|
|
2593
|
+
});
|
|
2594
|
+
this.record(session.sessionId, 'issue_panel_ruled', {
|
|
2595
|
+
issueId: issue.issueId,
|
|
2596
|
+
disposition: resolution.disposition,
|
|
2597
|
+
reason: resolution.reason,
|
|
2598
|
+
status,
|
|
2599
|
+
severity: issue.severity,
|
|
2600
|
+
supporters: resolution.supporters,
|
|
2601
|
+
rejecters: resolution.rejecters
|
|
2602
|
+
});
|
|
1703
2603
|
continue;
|
|
1704
2604
|
}
|
|
1705
2605
|
if (this.store.findPendingEscalation(session.sessionId, 'issue_dispute', issue.issueId))
|
|
1706
2606
|
continue;
|
|
1707
2607
|
this.store.updateIssue(issue.issueId, { status: 'escalated' });
|
|
1708
|
-
const voteHistory = this.store
|
|
1709
|
-
|
|
2608
|
+
const voteHistory = this.store
|
|
2609
|
+
.listIssueVotes(session.sessionId)
|
|
2610
|
+
.filter(vote => vote.issueId === issue.issueId)
|
|
2611
|
+
.map(vote => ({
|
|
2612
|
+
participantId: vote.participantId,
|
|
2613
|
+
stance: `vote round ${vote.consensusRound}: ${vote.stance}`,
|
|
2614
|
+
rationale: vote.rationale ?? 'approved without an additional rationale'
|
|
2615
|
+
}));
|
|
2616
|
+
const discussions = this.store
|
|
2617
|
+
.listIssueMessages(issue.issueId)
|
|
2618
|
+
.filter(message => message.kind === 'discussion')
|
|
2619
|
+
.map(message => ({
|
|
2620
|
+
participantId: message.authorId,
|
|
2621
|
+
stance: `discussion round ${message.payload.consensusRound ?? '?'}`,
|
|
2622
|
+
rationale: String(message.payload.argument ?? '')
|
|
2623
|
+
}));
|
|
1710
2624
|
const current = issueConsensus(snapshot).find(entry => entry.issueId === issue.issueId);
|
|
1711
|
-
const implicit = (current?.positions ?? [])
|
|
1712
|
-
|
|
2625
|
+
const implicit = (current?.positions ?? [])
|
|
2626
|
+
.filter(position => position.implicit)
|
|
2627
|
+
.map(position => ({
|
|
2628
|
+
participantId: position.participantId,
|
|
2629
|
+
stance: 'initial approve (reporter)',
|
|
2630
|
+
rationale: 'issue reporter implicitly approves'
|
|
2631
|
+
}));
|
|
2632
|
+
const escalation = this.store.createEscalation({
|
|
2633
|
+
sessionId: session.sessionId,
|
|
2634
|
+
kind: 'issue_dispute',
|
|
2635
|
+
refId: issue.issueId,
|
|
2636
|
+
raisedBy: 'system',
|
|
1713
2637
|
summary: resolution.reason === 'panel_unanimously_rejected'
|
|
1714
2638
|
? `Every other reviewer rejected this ${issue.severity} finding, but its reporter did not withdraw it: ${issue.title}`
|
|
1715
2639
|
: `The panel split on this ${issue.severity} finding and did not converge in ${session.debateRound} discussion round(s): ${issue.title}`,
|
|
1716
|
-
positions: [...implicit, ...voteHistory, ...discussions],
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
2640
|
+
positions: [...implicit, ...voteHistory, ...discussions],
|
|
2641
|
+
question: `Should "${issue.title}" proceed to implementation?`,
|
|
2642
|
+
options: ['valid issue', 'not an issue', 'needs more investigation'],
|
|
2643
|
+
urgency: issue.severity === 'blocker' || issue.severity === 'critical' ? 'high' : 'normal'
|
|
2644
|
+
});
|
|
2645
|
+
this.record(session.sessionId, 'escalation_raised', {
|
|
2646
|
+
escalationId: escalation.escalationId,
|
|
2647
|
+
kind: 'issue_dispute',
|
|
2648
|
+
refId: issue.issueId,
|
|
2649
|
+
reason: resolution.reason,
|
|
2650
|
+
supporters: resolution.supporters,
|
|
2651
|
+
rejecters: resolution.rejecters
|
|
2652
|
+
});
|
|
1720
2653
|
}
|
|
1721
2654
|
}
|
|
1722
2655
|
raiseDeadlockEscalation(session) {
|
|
1723
2656
|
if (this.store.findPendingEscalation(session.sessionId, 'other'))
|
|
1724
2657
|
return;
|
|
1725
|
-
const open = this.store
|
|
1726
|
-
|
|
2658
|
+
const open = this.store
|
|
2659
|
+
.listIssues(session.sessionId)
|
|
2660
|
+
.filter(issue => ['open', 'answered', 'escalated'].includes(issue.status));
|
|
2661
|
+
const escalation = this.store.createEscalation({
|
|
2662
|
+
sessionId: session.sessionId,
|
|
2663
|
+
kind: 'other',
|
|
2664
|
+
raisedBy: 'system',
|
|
1727
2665
|
summary: `The session reached its ${session.policy.maxTotalRounds}-round cap with ${open.length} issue(s) still open.`,
|
|
1728
|
-
positions: [],
|
|
1729
|
-
|
|
2666
|
+
positions: [],
|
|
2667
|
+
question: 'How should the remaining issues be handled?',
|
|
2668
|
+
options: ['rule on each issue', 'raise the round cap', 'close the session as is'],
|
|
2669
|
+
urgency: 'high'
|
|
2670
|
+
});
|
|
2671
|
+
this.record(session.sessionId, 'escalation_raised', {
|
|
2672
|
+
escalationId: escalation.escalationId,
|
|
2673
|
+
kind: 'other',
|
|
2674
|
+
reason: 'max_total_rounds_reached'
|
|
2675
|
+
});
|
|
1730
2676
|
}
|
|
1731
2677
|
/** Marks a stall for humans and dashboards. It deliberately does not change the phase. */
|
|
1732
2678
|
/**
|
|
@@ -1750,7 +2696,10 @@ export class CollabHub {
|
|
|
1750
2696
|
}
|
|
1751
2697
|
/** Participants plus the fact a human needs when a session looks frozen: what is still queued undelivered. */
|
|
1752
2698
|
participantsForHuman(sessionId) {
|
|
1753
|
-
return this.store.listParticipants(sessionId).map(participant => ({
|
|
2699
|
+
return this.store.listParticipants(sessionId).map(participant => ({
|
|
2700
|
+
...participant,
|
|
2701
|
+
pendingTasks: this.store.listInbox(participant.participantId).length
|
|
2702
|
+
}));
|
|
1754
2703
|
}
|
|
1755
2704
|
checkStalls() {
|
|
1756
2705
|
const changed = [];
|
|
@@ -1758,7 +2707,9 @@ export class CollabHub {
|
|
|
1758
2707
|
const snapshot = this.snapshot(session.sessionId), allPending = waitingOn(snapshot), participants = this.store.listParticipants(session.sessionId);
|
|
1759
2708
|
// A long review can legitimately exceed the warning window. `streaming`/`starting` means Pi is still
|
|
1760
2709
|
// actively doing the assigned work, not that the seat forgot to submit its API result.
|
|
1761
|
-
const busy = new Set(participants
|
|
2710
|
+
const busy = new Set(participants
|
|
2711
|
+
.filter(participant => ['starting', 'streaming', 'stopping'].includes(this.options.agentStatus?.(participant.agentId) ?? ''))
|
|
2712
|
+
.map(participant => participant.participantId));
|
|
1762
2713
|
const pending = allPending.filter(participantId => !busy.has(participantId));
|
|
1763
2714
|
const unclaimed = this.undeliveredTaskOwners(session.sessionId, session.policy.overdueWarningSec, pending);
|
|
1764
2715
|
if (session.stalled) {
|
|
@@ -1778,16 +2729,29 @@ export class CollabHub {
|
|
|
1778
2729
|
continue;
|
|
1779
2730
|
}
|
|
1780
2731
|
if (unclaimed.length) {
|
|
1781
|
-
const updated = this.store.updateSession(session.sessionId, {
|
|
1782
|
-
|
|
2732
|
+
const updated = this.store.updateSession(session.sessionId, {
|
|
2733
|
+
stalled: { since: new Date(this.now()).toISOString(), waitingOn: unclaimed }
|
|
2734
|
+
});
|
|
2735
|
+
this.record(session.sessionId, 'participant_overdue', {
|
|
2736
|
+
waitingOn: unclaimed,
|
|
2737
|
+
overdueBySec: 0,
|
|
2738
|
+
phase: session.phase,
|
|
2739
|
+
reason: 'task_never_delivered'
|
|
2740
|
+
});
|
|
1783
2741
|
changed.push(updated);
|
|
1784
2742
|
continue;
|
|
1785
2743
|
}
|
|
1786
2744
|
const check = stallCheck(snapshot, Date.parse(session.updatedAt), this.now());
|
|
1787
2745
|
if (!check.stalled || !pending.length)
|
|
1788
2746
|
continue;
|
|
1789
|
-
const updated = this.store.updateSession(session.sessionId, {
|
|
1790
|
-
|
|
2747
|
+
const updated = this.store.updateSession(session.sessionId, {
|
|
2748
|
+
stalled: { since: new Date(this.now()).toISOString(), waitingOn: pending }
|
|
2749
|
+
});
|
|
2750
|
+
this.record(session.sessionId, 'participant_overdue', {
|
|
2751
|
+
waitingOn: pending,
|
|
2752
|
+
overdueBySec: check.overdueBySec,
|
|
2753
|
+
phase: session.phase
|
|
2754
|
+
});
|
|
1791
2755
|
changed.push(updated);
|
|
1792
2756
|
}
|
|
1793
2757
|
return changed;
|
|
@@ -1797,10 +2761,21 @@ export class CollabHub {
|
|
|
1797
2761
|
* for review feedback would sit there forever (or start polling), because a clean review pushes no other task.
|
|
1798
2762
|
*/
|
|
1799
2763
|
announceFinish(session) {
|
|
1800
|
-
const participants = this.store
|
|
2764
|
+
const participants = this.store
|
|
2765
|
+
.listParticipants(session.sessionId)
|
|
2766
|
+
.filter(participant => participant.state !== 'left');
|
|
1801
2767
|
for (const participant of participants) {
|
|
1802
|
-
this.push(session.sessionId, participant.participantId, 'session_result', {
|
|
1803
|
-
|
|
2768
|
+
this.push(session.sessionId, participant.participantId, 'session_result', {
|
|
2769
|
+
phase: session.phase,
|
|
2770
|
+
round: session.round,
|
|
2771
|
+
outcome: session.outcome ?? {}
|
|
2772
|
+
});
|
|
2773
|
+
this.record(session.sessionId, 'task_assigned', {
|
|
2774
|
+
participantId: participant.participantId,
|
|
2775
|
+
task: 'session_result',
|
|
2776
|
+
phase: session.phase,
|
|
2777
|
+
round: session.round
|
|
2778
|
+
});
|
|
1804
2779
|
}
|
|
1805
2780
|
}
|
|
1806
2781
|
outcome(sessionId) {
|
|
@@ -1809,14 +2784,27 @@ export class CollabHub {
|
|
|
1809
2784
|
byStatus[issue.status] = (byStatus[issue.status] ?? 0) + 1;
|
|
1810
2785
|
const snapshot = this.snapshot(sessionId), approval = approvalSummary(snapshot);
|
|
1811
2786
|
const summary = this.reviewSummary(sessionId);
|
|
1812
|
-
return {
|
|
2787
|
+
return {
|
|
2788
|
+
totalIssues: issues.length,
|
|
2789
|
+
byStatus,
|
|
1813
2790
|
// A review now closes with confirmed action items. The verdict describes the report, not whether every
|
|
1814
2791
|
// finding went through the removed response/adjudication negotiation.
|
|
1815
2792
|
verdict: summary.verdict,
|
|
1816
2793
|
approval,
|
|
1817
|
-
participants: this.store.listParticipants(sessionId).map(participant => ({
|
|
2794
|
+
participants: this.store.listParticipants(sessionId).map(participant => ({
|
|
2795
|
+
participantId: participant.participantId,
|
|
2796
|
+
displayName: participant.displayName,
|
|
2797
|
+
role: participant.role,
|
|
2798
|
+
model: participant.model,
|
|
2799
|
+
tokensUsed: participant.tokensUsed,
|
|
2800
|
+
tokensEstimated: participant.tokensEstimated,
|
|
2801
|
+
state: participant.state
|
|
2802
|
+
}))
|
|
2803
|
+
};
|
|
2804
|
+
}
|
|
2805
|
+
withHistory(issue) {
|
|
2806
|
+
return { ...issue, history: this.store.listIssueMessages(issue.issueId) };
|
|
1818
2807
|
}
|
|
1819
|
-
withHistory(issue) { return { ...issue, history: this.store.listIssueMessages(issue.issueId) }; }
|
|
1820
2808
|
/**
|
|
1821
2809
|
* Flags likely duplicates without merging: a wrong merge silently drops a real defect. While collection is
|
|
1822
2810
|
* blind the hint is restricted to issues the caller may already read, otherwise the id alone (and a GET on it)
|
|
@@ -1831,7 +2819,7 @@ export class CollabHub {
|
|
|
1831
2819
|
if (!issue)
|
|
1832
2820
|
continue;
|
|
1833
2821
|
for (const other of all) {
|
|
1834
|
-
if (other.issueId === issueId || issueIds.includes(other.issueId) && other.issueId > issueId)
|
|
2822
|
+
if (other.issueId === issueId || (issueIds.includes(other.issueId) && other.issueId > issueId))
|
|
1835
2823
|
continue;
|
|
1836
2824
|
if (other.issueId === issueId)
|
|
1837
2825
|
continue;
|
|
@@ -1844,37 +2832,57 @@ export class CollabHub {
|
|
|
1844
2832
|
}
|
|
1845
2833
|
return results;
|
|
1846
2834
|
}
|
|
1847
|
-
replay(participant, clientRequestId) {
|
|
2835
|
+
replay(participant, clientRequestId) {
|
|
2836
|
+
return this.store.getIdempotent(`${participant.participantId}:${clientRequestId}`);
|
|
2837
|
+
}
|
|
1848
2838
|
finish(participant, clientRequestId, body, usage, response) {
|
|
1849
2839
|
this.store.saveIdempotent(`${participant.participantId}:${clientRequestId}`, participant.sessionId, participant.participantId, response);
|
|
1850
2840
|
this.chargeUsage(participant, body, usage);
|
|
1851
2841
|
}
|
|
1852
2842
|
/** An agent that reports real usage is charged that; one that stays silent is charged a byte-based estimate. */
|
|
1853
2843
|
chargeUsage(participant, body, usage) {
|
|
1854
|
-
const reported = usage?.totalTokens ?? (
|
|
2844
|
+
const reported = usage?.totalTokens ?? (usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0);
|
|
1855
2845
|
const estimated = !reported;
|
|
1856
2846
|
const tokens = reported || Math.ceil((JSON.stringify(body)?.length ?? 0) / 4);
|
|
1857
2847
|
const updated = this.store.addTokenUsage(participant.participantId, tokens, estimated);
|
|
1858
2848
|
if (updated.state === 'budget_exhausted' && participant.state !== 'budget_exhausted') {
|
|
1859
|
-
this.record(participant.sessionId, 'budget_exhausted', {
|
|
2849
|
+
this.record(participant.sessionId, 'budget_exhausted', {
|
|
2850
|
+
participantId: participant.participantId,
|
|
2851
|
+
tokensUsed: updated.tokensUsed,
|
|
2852
|
+
tokenBudget: updated.tokenBudget
|
|
2853
|
+
});
|
|
1860
2854
|
if (!this.store.findPendingEscalation(participant.sessionId, 'budget_exhausted', participant.participantId)) {
|
|
1861
|
-
const escalation = this.store.createEscalation({
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
2855
|
+
const escalation = this.store.createEscalation({
|
|
2856
|
+
sessionId: participant.sessionId,
|
|
2857
|
+
kind: 'budget_exhausted',
|
|
2858
|
+
refId: participant.participantId,
|
|
2859
|
+
raisedBy: 'system',
|
|
2860
|
+
summary: `${participant.displayName} spent its ${updated.tokenBudget} token budget and can no longer submit.`,
|
|
2861
|
+
positions: [],
|
|
2862
|
+
question: 'Raise the budget, replace the agent, or force the phase forward?',
|
|
2863
|
+
options: ['raise budget', 'replace participant', 'force advance'],
|
|
2864
|
+
urgency: 'high'
|
|
2865
|
+
});
|
|
2866
|
+
this.record(participant.sessionId, 'escalation_raised', {
|
|
2867
|
+
escalationId: escalation.escalationId,
|
|
2868
|
+
kind: 'budget_exhausted',
|
|
2869
|
+
refId: participant.participantId
|
|
2870
|
+
});
|
|
1865
2871
|
}
|
|
1866
2872
|
}
|
|
1867
2873
|
return updated;
|
|
1868
2874
|
}
|
|
1869
|
-
toFlowParticipant(participant) {
|
|
1870
|
-
|
|
1871
|
-
}
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
2875
|
+
toFlowParticipant(participant) {
|
|
2876
|
+
return { participantId: participant.participantId, role: participant.role, state: participant.state };
|
|
2877
|
+
}
|
|
2878
|
+
toFlowIssue(issue) {
|
|
2879
|
+
return {
|
|
2880
|
+
issueId: issue.issueId,
|
|
2881
|
+
reporterId: issue.reporterId,
|
|
2882
|
+
targetParticipantId: issue.targetParticipantId,
|
|
2883
|
+
status: issue.status,
|
|
2884
|
+
round: issue.round,
|
|
2885
|
+
severity: issue.severity
|
|
2886
|
+
};
|
|
2887
|
+
}
|
|
1880
2888
|
}
|