@yemi33/minions 0.1.2382 → 0.1.2383

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.
@@ -18,6 +18,7 @@
18
18
  * worker exists and the pool is already at its configured size.
19
19
  * WorkerHandle: {
20
20
  * dispatchId,
21
+ * currentModel, // ACP-selected session model
21
22
  * stream(promptText, opts) → Promise, // ACP session/prompt turn
22
23
  * cancel(), // ACP session/cancel
23
24
  * release(), // return the worker to the
@@ -133,7 +134,8 @@ function _pump() {
133
134
  const req = _queue[0];
134
135
 
135
136
  const matchIdx = _free.findIndex((w) =>
136
- w.mcpServersHash === req.mcpServersHash
137
+ w.runtime === req.runtime
138
+ && w.mcpServersHash === req.mcpServersHash
137
139
  && w.processContextHash === req.processContextHash
138
140
  );
139
141
  if (matchIdx >= 0) {
@@ -230,6 +232,7 @@ async function _spawnFreshWorker(req) {
230
232
  // queued request can spawn its replacement.
231
233
  async function _bootWorker(req) {
232
234
  const worker = new Worker({
235
+ runtime: req.runtime,
233
236
  id: `agent-pool-${_nextWorkerId++}`,
234
237
  model: req.model,
235
238
  effort: req.effort,
@@ -312,7 +315,7 @@ function _clearLeaseContext(dispatchId) {
312
315
  }
313
316
 
314
317
  async function acquireWorker({
315
- dispatchId, cwd, model, effort, mcpServers, hermeticDirs, processEnv, sessionContext,
318
+ runtime, dispatchId, cwd, model, effort, mcpServers, hermeticDirs, processEnv, sessionContext,
316
319
  } = {}) {
317
320
  if (!dispatchId) throw new Error('agent-worker-pool.acquireWorker: dispatchId is required');
318
321
  if (_draining) throw new Error('agent-worker-pool: draining');
@@ -324,6 +327,7 @@ async function acquireWorker({
324
327
  }
325
328
 
326
329
  const mcpServersHash = hashMcpServers(mcpServers);
330
+ const runtimeAdapter = transport.resolveWorkerRuntime(runtime);
327
331
  const workerProcessEnv = processEnv && typeof processEnv === 'object'
328
332
  ? Object.freeze({ ...processEnv })
329
333
  : undefined;
@@ -332,6 +336,7 @@ async function acquireWorker({
332
336
  ? { ...sessionContext }
333
337
  : {};
334
338
  const req = {
339
+ runtime: runtimeAdapter,
335
340
  dispatchId, cwd, model, effort, mcpServers, mcpServersHash,
336
341
  processEnv: workerProcessEnv, processContextHash,
337
342
  };
@@ -365,12 +370,14 @@ async function acquireWorker({
365
370
  dispatchId,
366
371
  // P-1d8f0b93 — exposed so engine.js's PooledAgentProcess facade (the
367
372
  // spawnAgent integration) can write the same PID-file/log identity a
368
- // cold-spawned child_process would, and stamp the ACP sessionId into the
369
- // synthesized `result` event for parseOutput parity. Both are read-time
373
+ // cold-spawned child_process would, stamp the ACP sessionId into the
374
+ // synthesized `result` event, and expose the model selected by session/new.
375
+ // All are read-time
370
376
  // snapshots of the underlying worker's current values (a getter, not a
371
377
  // captured copy), since sessionId can rotate under a reused worker.
372
378
  get pid() { return (worker.proc && worker.proc.pid) || null; },
373
379
  get sessionId() { return worker.sessionId || null; },
380
+ get currentModel() { return worker.currentModel || null; },
374
381
  stream: async (promptText, opts = {}) => {
375
382
  try {
376
383
  return await worker.stream(promptText, {
@@ -151,8 +151,9 @@ function _trace(...parts) {
151
151
 
152
152
  // ── Public API ────────────────────────────────────────────────────────────
153
153
 
154
- async function getSession({ tabId, model, effort, mcpServers, systemPromptHash, cwd } = {}) {
154
+ async function getSession({ runtime, tabId, model, effort, mcpServers, systemPromptHash, cwd } = {}) {
155
155
  if (!tabId) throw new Error('cc-worker-pool.getSession: tabId is required');
156
+ const runtimeAdapter = transport.resolveWorkerRuntime(runtime);
156
157
  const mcpServersHash = _hashMcpServers(mcpServers);
157
158
  let worker = _tabs.get(tabId);
158
159
  // Track which lifecycle path we took so the dashboard's [cc-timing] log can
@@ -195,7 +196,7 @@ async function getSession({ tabId, model, effort, mcpServers, systemPromptHash,
195
196
  if (worker.killed) {
196
197
  _tabs.delete(tabId);
197
198
  worker = null;
198
- } else if (worker.mcpServersHash !== mcpServersHash) {
199
+ } else if (worker.runtime !== runtimeAdapter || worker.mcpServersHash !== mcpServersHash) {
199
200
  // mcpServers shape changed → must respawn the proc; the daemon
200
201
  // resolves MCP server config at process boot, not per session.
201
202
  _tabs.delete(tabId);
@@ -222,6 +223,7 @@ async function getSession({ tabId, model, effort, mcpServers, systemPromptHash,
222
223
 
223
224
  if (!worker) {
224
225
  worker = new Worker({
226
+ runtime: runtimeAdapter,
225
227
  id: tabId, model, effort, mcpServers, mcpServersHash, systemPromptHash, cwd,
226
228
  internals: _internals, trace: _trace,
227
229
  });
package/engine/cli.js CHANGED
@@ -9,6 +9,7 @@ const shared = require('./shared');
9
9
  const { safeRead, safeJson, safeJsonArr, safeWrite, readWorkItems, readPullRequests, mutateControl, mutateWorkItems, ts, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, REVIEW_STATUS, DISPATCH_RESULT, FAILURE_CLASS } = shared;
10
10
  const queries = require('./queries');
11
11
  const agentWorkerPool = require('./agent-worker-pool');
12
+ const { resolveCommentExecutionModel } = require('./execution-model');
12
13
  const { getConfig, getControl, getDispatch, getAgentStatus,
13
14
  MINIONS_DIR, ENGINE_DIR, AGENTS_DIR, PLANS_DIR, PRD_DIR, CONTROL_PATH } = queries;
14
15
 
@@ -2076,6 +2077,11 @@ const commands = {
2076
2077
  console.error('error: must supply either --body-file <path> or --body <text>');
2077
2078
  process.exit(2);
2078
2079
  }
2080
+ const executionModel = resolveCommentExecutionModel({
2081
+ dispatch: getDispatch(),
2082
+ agentId,
2083
+ workItemId,
2084
+ });
2079
2085
 
2080
2086
  // ── Azure DevOps: <prNumber> --host ado --ado-org --ado-project --repo-id ──
2081
2087
  if (isAdo) {
@@ -2097,6 +2103,7 @@ const commands = {
2097
2103
  const adoComment = require('./ado-comment');
2098
2104
  adoComment.postAdoPrComment({
2099
2105
  orgBase, project, repositoryId, prNumber, body, agentId, kind, workItemId,
2106
+ model: executionModel.model,
2100
2107
  resolved: flags.resolved === true,
2101
2108
  }).then((result) => {
2102
2109
  if (result && result.threadId) {
@@ -2135,7 +2142,7 @@ const commands = {
2135
2142
 
2136
2143
  try {
2137
2144
  const result = ghComment.postPrComment({
2138
- repo, prNumber, body, agentId, kind, workItemId,
2145
+ repo, prNumber, body, agentId, kind, workItemId, model: executionModel.model,
2139
2146
  });
2140
2147
  if (result.output) console.log(result.output);
2141
2148
  } catch (e) {
@@ -2274,6 +2281,7 @@ module.exports = {
2274
2281
  _readDispatchPid: readDispatchPid,
2275
2282
  _normalizeSessionBranch: normalizeSessionBranch,
2276
2283
  _dispatchSessionBranch: dispatchSessionBranch,
2284
+ _resolveCommentExecutionModel: resolveCommentExecutionModel, // exported for testing
2277
2285
  // W-mpcyvff6000pf828 (#2653) — heartbeat writer + factory exported for tests
2278
2286
  _writeHeartbeatNow: writeHeartbeatNow,
2279
2287
  _createHeartbeatInterval: createHeartbeatInterval,
@@ -48,14 +48,17 @@ function linkifyBrandTrailer(text) {
48
48
  // org/project/repoId).
49
49
  //
50
50
  // Marker format (single line, ASCII):
51
- // <!-- minions:agent=<agentId> kind=<kind> wi=<workItemId> -->
51
+ // <!-- minions:agent=<agentId> kind=<kind> wi=<workItemId> model=<modelId> -->
52
52
  //
53
53
  // Validation (enforced before the marker is built):
54
54
  // - agentId /^[a-z][a-z0-9-]{0,30}$/
55
55
  // - kind /^[a-z][a-z0-9-]{0,30}$/
56
56
  // - workItemId /^[A-Z]-[a-z0-9]+$/ (optional)
57
+ // - model concrete runtime execution model (optional)
57
58
  // - no field may contain `--` (would close the HTML comment early)
58
59
 
60
+ const { normalizeExecutionModel } = require('./execution-model');
61
+
59
62
  const AGENT_ID_RE = /^[a-z][a-z0-9-]{0,30}$/;
60
63
  const KIND_RE = /^[a-z][a-z0-9-]{0,30}$/;
61
64
  const WORK_ITEM_ID_RE = /^[A-Z]-[a-z0-9]+$/;
@@ -63,7 +66,7 @@ const WORK_ITEM_ID_RE = /^[A-Z]-[a-z0-9]+$/;
63
66
  // Marker line (single-line HTML comment). Multiline flag so it matches at the
64
67
  // start of any line — required for round-trip detection of the builder output.
65
68
  const MINIONS_COMMENT_MARKER_RE =
66
- /^<!--\s*minions:agent=([^\s]+)\s+kind=([^\s]+)(?:\s+wi=([^\s]+))?\s*-->/m;
69
+ /^<!--\s*minions:agent=([^\s]+)\s+kind=([^\s]+)(?:\s+wi=([^\s]+))?(?:\s+model=([^\s]+))?\s*-->/m;
67
70
 
68
71
  // Canonical-format sample marker for tests/fixtures that need a marked body but
69
72
  // don't care about the specific fields.
@@ -85,18 +88,44 @@ function _validateField(name, value, re) {
85
88
  }
86
89
  }
87
90
 
88
- function _validateMarkerInputs({ agentId, kind, workItemId }) {
91
+ function _validateMarkerInputs({ agentId, kind, workItemId, model }) {
89
92
  _validateField('agentId', agentId, AGENT_ID_RE);
90
93
  _validateField('kind', kind, KIND_RE);
91
94
  if (workItemId !== undefined && workItemId !== null) {
92
95
  _validateField('workItemId', workItemId, WORK_ITEM_ID_RE);
93
96
  }
97
+ if (model !== undefined && model !== null && !normalizeExecutionModel(model)) {
98
+ throw new Error(`invalid model: ${JSON.stringify(model)}`);
99
+ }
100
+ }
101
+
102
+ function _encodeMarkerModel(model) {
103
+ const encoded = encodeURIComponent(model);
104
+ return encoded.includes('--') ? encoded.replace(/-/g, '%2D') : encoded;
94
105
  }
95
106
 
96
- function _buildMarker({ agentId, kind, workItemId }) {
97
- _validateMarkerInputs({ agentId, kind, workItemId });
107
+ function _buildMarker({ agentId, kind, workItemId, model }) {
108
+ _validateMarkerInputs({ agentId, kind, workItemId, model });
98
109
  const wi = (workItemId !== undefined && workItemId !== null) ? ` wi=${workItemId}` : '';
99
- return `<!-- minions:agent=${agentId} kind=${kind}${wi} -->`;
110
+ const modelPart = model ? ` model=${_encodeMarkerModel(model)}` : '';
111
+ return `<!-- minions:agent=${agentId} kind=${kind}${wi}${modelPart} -->`;
112
+ }
113
+
114
+ const MODEL_SIGNOFF_RE =
115
+ /(\b(?:Review|Reviewed|Fixed|Verified|Rebased) by (?:Minions|\[Minions\]\([^)]+\)) \([^\r\n)]*? · )[^)\r\n]+(\))/g;
116
+
117
+ function stampExecutionModel(text, model) {
118
+ const normalized = normalizeExecutionModel(model);
119
+ if (!normalized || typeof text !== 'string') return text;
120
+ return text.replace(MODEL_SIGNOFF_RE, (_match, prefix, suffix) => `${prefix}${normalized}${suffix}`);
121
+ }
122
+
123
+ function _stampLeadingMarkerModel(text, model) {
124
+ if (!model || typeof text !== 'string') return text;
125
+ return text.replace(MINIONS_COMMENT_MARKER_RE, (marker, agentId, kind, workItemId) => {
126
+ if (marker !== text.slice(0, marker.length)) return marker;
127
+ return _buildMarker({ agentId, kind, workItemId, model });
128
+ });
100
129
  }
101
130
 
102
131
  /**
@@ -106,41 +135,49 @@ function _buildMarker({ agentId, kind, workItemId }) {
106
135
  * Idempotency: if `body` already starts with a minions marker it is returned
107
136
  * with the brand transform applied but no second marker prepended.
108
137
  *
109
- * @param {{agentId:string, kind:string, workItemId?:string, body?:string}} args
138
+ * @param {{agentId:string, kind:string, workItemId?:string, model?:string, body?:string}} args
110
139
  * @returns {string} the final comment body
111
140
  */
112
- function buildMinionsCommentBody({ agentId, kind, workItemId, body }) {
141
+ function buildMinionsCommentBody({ agentId, kind, workItemId, model, body }) {
113
142
  // Validate the inputs even when the body is pre-marked, so callers can't
114
143
  // silently bypass validation by pre-marking their body.
115
- _validateMarkerInputs({ agentId, kind, workItemId });
144
+ _validateMarkerInputs({ agentId, kind, workItemId, model });
116
145
  let safeBody = body == null ? '' : String(body);
117
146
 
147
+ safeBody = stampExecutionModel(safeBody, model);
148
+
118
149
  // Brand-link safety net: deterministically hyperlink the first bare
119
150
  // "… by Minions" signature trailer (no-op when the agent already linked it).
120
151
  safeBody = linkifyBrandTrailer(safeBody);
121
152
 
122
- if (_LEADING_MARKER_RE.test(safeBody)) return safeBody;
123
- const marker = _buildMarker({ agentId, kind, workItemId });
153
+ if (_LEADING_MARKER_RE.test(safeBody)) return _stampLeadingMarkerModel(safeBody, model);
154
+ const marker = _buildMarker({ agentId, kind, workItemId, model });
124
155
  return `${marker}\n\n${safeBody}`;
125
156
  }
126
157
 
127
158
  /**
128
- * Inverse of the marker builder: parse `{agentId, kind, workItemId}` out of a
129
- * marked body, or null when no marker is present.
159
+ * Inverse of the marker builder: parse `{agentId, kind, workItemId, model?}`
160
+ * out of a marked body, or null when no marker is present.
130
161
  */
131
162
  function parseMinionsMarker(body) {
132
163
  if (typeof body !== 'string' || body.length === 0) return null;
133
164
  const m = body.match(MINIONS_COMMENT_MARKER_RE);
134
165
  if (!m) return null;
135
- return {
166
+ const parsed = {
136
167
  agentId: m[1],
137
168
  kind: m[2],
138
169
  workItemId: m[3] === undefined ? undefined : m[3],
139
170
  };
171
+ if (m[4] !== undefined) {
172
+ try { parsed.model = decodeURIComponent(m[4]); }
173
+ catch { return null; }
174
+ }
175
+ return parsed;
140
176
  }
141
177
 
142
178
  module.exports = {
143
179
  linkifyBrandTrailer,
180
+ stampExecutionModel,
144
181
  MINIONS_BRAND_URL,
145
182
  // Neutral marker + body builder (consumed by gh-comment.js and ado-comment.js).
146
183
  buildMinionsCommentBody,
@@ -978,7 +978,12 @@ function markCompletedSourceWorkItem(item) {
978
978
  // ─── Complete Dispatch ───────────────────────────────────────────────────────
979
979
 
980
980
  function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', resultSummary = '', opts = {}) {
981
- const { processWorkItemFailure = true, processWorkItemSuccess = false, failureClass } = opts;
981
+ const {
982
+ processWorkItemFailure = true,
983
+ processWorkItemSuccess = false,
984
+ countAgentRetryFailure = true,
985
+ failureClass,
986
+ } = opts;
982
987
  const agentRetryable = normalizeRetryableDecision(opts.agentRetryable ?? opts.retryable);
983
988
  let item = null;
984
989
  let completedSourceWorkItem = false;
@@ -1185,7 +1190,7 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
1185
1190
  // agent hits the threshold. Skip when no agent is resolvable
1186
1191
  // (anonymous failures shouldn't corrupt the map shape).
1187
1192
  const failedAgent = item.agent || wi.dispatched_to;
1188
- if (failedAgent) shared.bumpAgentRetryCount(wi, failedAgent);
1193
+ if (failedAgent && countAgentRetryFailure) shared.bumpAgentRetryCount(wi, failedAgent);
1189
1194
  delete wi.failReason;
1190
1195
  delete wi.failedAt;
1191
1196
  delete wi.dispatched_at;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Runtime-model evidence shared by dispatch, PR-comment, and review persistence.
3
+ *
4
+ * Priority is deliberate: runtime completion output is authoritative, an
5
+ * earlier stream/session capture is next, and the requested model is only a
6
+ * fallback when Minions explicitly sent one to the runtime. No catalog-based
7
+ * default inference belongs here.
8
+ */
9
+
10
+ const UNKNOWN_MODEL = 'unknown';
11
+ const MODEL_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
12
+
13
+ function normalizeExecutionModel(value) {
14
+ if (typeof value !== 'string') return null;
15
+ const model = value.trim();
16
+ return MODEL_ID_RE.test(model) ? model : null;
17
+ }
18
+
19
+ function resolveExecutionModel({
20
+ reportedModel,
21
+ capturedModel,
22
+ requestedModel,
23
+ } = {}) {
24
+ for (const [source, value] of [
25
+ ['reported', reportedModel],
26
+ ['captured', capturedModel],
27
+ ['requested', requestedModel],
28
+ ]) {
29
+ const model = normalizeExecutionModel(value);
30
+ if (model) return { model, source };
31
+ }
32
+ return { model: UNKNOWN_MODEL, source: 'unknown' };
33
+ }
34
+
35
+ function _dispatchWorkItemId(item) {
36
+ return item?.meta?.item?.id
37
+ || item?.meta?.workItemId
38
+ || item?.workItemId
39
+ || null;
40
+ }
41
+
42
+ function resolveCommentExecutionModel({
43
+ dispatch,
44
+ agentId,
45
+ workItemId,
46
+ } = {}) {
47
+ const active = Array.isArray(dispatch?.active) ? dispatch.active : [];
48
+ const candidates = active.filter((item) => {
49
+ if (String(item?.agent || '').toLowerCase() !== String(agentId || '').toLowerCase()) return false;
50
+ if (!workItemId) return true;
51
+ return item.id === workItemId || _dispatchWorkItemId(item) === workItemId;
52
+ });
53
+ candidates.sort((a, b) => String(b.started_at || '').localeCompare(String(a.started_at || '')));
54
+ const item = candidates[0] || null;
55
+ if (!item) return { model: null, source: 'none' };
56
+ return resolveExecutionModel({
57
+ capturedModel: item?.executionModel,
58
+ requestedModel: item?.requestedModel,
59
+ });
60
+ }
61
+
62
+ module.exports = {
63
+ UNKNOWN_MODEL,
64
+ MODEL_ID_RE,
65
+ normalizeExecutionModel,
66
+ resolveExecutionModel,
67
+ resolveCommentExecutionModel,
68
+ };
@@ -4,7 +4,7 @@
4
4
  * minions-authored PR comments by structure rather than by body shape.
5
5
  *
6
6
  * Marker format (single line, ASCII):
7
- * <!-- minions:agent=<agentId> kind=<kind> wi=<workItemId> -->
7
+ * <!-- minions:agent=<agentId> kind=<kind> wi=<workItemId> model=<modelId> -->
8
8
  *
9
9
  * Followed by `\n\n` and then the caller-provided body. `wi=` is omitted when
10
10
  * no workItemId is supplied. The marker is intentionally minimal so it round-
@@ -14,6 +14,7 @@
14
14
  * - agentId /^[a-z][a-z0-9-]{0,30}$/ (lowercase, hyphenated, ≤31 chars)
15
15
  * - kind /^[a-z][a-z0-9-]{0,30}$/ (same shape — categorical tag)
16
16
  * - workItemId /^[A-Z]-[a-z0-9]+$/ (e.g. W-mp3bp0ha000997ab, P-d5a8c9b6)
17
+ * - model concrete runtime model, when active-dispatch evidence exists
17
18
  * - no field may contain `--` (would close the HTML comment early)
18
19
  * - no field may contain `=` `<` `>` `\n` `"` `'` `` ` `` ` ` (whitespace)
19
20
  *
@@ -153,13 +154,14 @@ function postPrComment({
153
154
  agentId,
154
155
  kind,
155
156
  workItemId,
157
+ model,
156
158
  timeoutMs = 30000,
157
159
  execFileSync = _execFileSync,
158
160
  resolveTokenForSlug,
159
161
  } = {}) {
160
162
  _validateRepo(repo);
161
163
  _validatePrNumber(prNumber);
162
- const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, body });
164
+ const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, model, body });
163
165
  const file = _writeTempBodyFile(finalBody);
164
166
  const env = _resolveTokenEnvForRepo(repo, resolveTokenForSlug);
165
167
  try {
@@ -182,13 +184,14 @@ function postPrReviewComment({
182
184
  agentId,
183
185
  kind,
184
186
  workItemId,
187
+ model,
185
188
  timeoutMs = 30000,
186
189
  execFileSync = _execFileSync,
187
190
  resolveTokenForSlug,
188
191
  } = {}) {
189
192
  _validateRepo(repo);
190
193
  _validatePrNumber(prNumber);
191
- const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, body });
194
+ const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, model, body });
192
195
  const file = _writeTempBodyFile(finalBody);
193
196
  const env = _resolveTokenEnvForRepo(repo, resolveTokenForSlug);
194
197
  try {
package/engine/github.js CHANGED
@@ -282,10 +282,10 @@ async function _resolveViewerLogin(slug) {
282
282
  // `_isMinionsAuthoredComment` cannot tell minions posts apart from human
283
283
  // posts (the `viewerDidAuthor` gate stays false for everything), so the
284
284
  // comment classifier reclassifies every minions comment as human and
285
- // queues a spurious fix dispatch on every poll cycle. The downstream
286
- // `check-self-authored-review-comment` skill no-ops the dispatch, so this
287
- // is a slot/token tax rather than state corruption — but the frequency
288
- // data is worth tracking so future audits can prioritize a deeper fix
285
+ // queues a spurious fix dispatch on every poll cycle. Review-fix completion
286
+ // now rejects shared-identity-only no-ops, so failing closed here prevents
287
+ // both duplicate traffic and false completion retries. The frequency
288
+ // data is still worth tracking so future audits can prioritize a deeper fix
289
289
  // (e.g. a credentialed re-probe of the gh CLI). Global counter under
290
290
  // `_engine.viewerLoginResolutionFailures`; surfaced through the existing
291
291
  // engine metrics aggregation (`getMetrics()` in `engine/queries.js`).
@@ -1241,9 +1241,8 @@ async function pollPrHumanComments(config) {
1241
1241
  // `c.viewerDidAuthor === true`, and `_backfillViewerDidAuthor` is a
1242
1242
  // no-op when viewerLogin is null. Running the classifier without a
1243
1243
  // login mass-misclassifies every minions comment as human and queues
1244
- // a redundant fix dispatch on every poll cycle (the downstream
1245
- // `check-self-authored-review-comment` skill no-ops them, so this is
1246
- // a slot/token tax — see audit OQ-Y, HIGH not CRITICAL). Skip the
1244
+ // a redundant fix dispatch on every poll cycle. Shared identity is not
1245
+ // valid review-fix no-op evidence, so skip the
1247
1246
  // entire round instead. One log + one counter increment per affected
1248
1247
  // PR per cycle (no per-comment fanout).
1249
1248
  if (!viewerLogin) {