devcontour-harness 0.1.0-alpha.13 → 0.1.0-alpha.18

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.
Files changed (73) hide show
  1. package/README.md +48 -2
  2. package/docs/architecture.md +77 -3
  3. package/docs/contracts.md +100 -0
  4. package/docs/quickstart.md +1 -1
  5. package/docs/release-notes/0.1.0-alpha.14.md +69 -0
  6. package/docs/release-notes/0.1.0-alpha.15.md +62 -0
  7. package/docs/release-notes/0.1.0-alpha.16.md +36 -0
  8. package/docs/release-notes/0.1.0-alpha.17.md +86 -0
  9. package/docs/release-notes/0.1.0-alpha.18.md +48 -0
  10. package/docs/review-probes.md +9 -3
  11. package/docs/review-protocol.md +9 -0
  12. package/docs/upgrade.md +16 -14
  13. package/lib/agents/cli-adapter.js +14 -10
  14. package/lib/agents/review-probe.d.ts +9 -0
  15. package/lib/agents/review-probe.js +23 -1
  16. package/lib/agents/schemas.d.ts +206 -0
  17. package/lib/agents/schemas.js +37 -1
  18. package/lib/agents/usage.d.ts +22 -0
  19. package/lib/agents/usage.js +112 -6
  20. package/lib/contracts/checks.d.ts +119 -0
  21. package/lib/contracts/checks.js +64 -1
  22. package/lib/contracts/cli.d.ts +30 -0
  23. package/lib/contracts/diagnosis.d.ts +3 -1
  24. package/lib/contracts/diagnosis.js +2 -0
  25. package/lib/contracts/events.d.ts +12 -0
  26. package/lib/contracts/execution.d.ts +109 -5
  27. package/lib/contracts/execution.js +58 -0
  28. package/lib/contracts/failure.d.ts +17 -3
  29. package/lib/contracts/failure.js +4 -2
  30. package/lib/contracts/published.d.ts +67 -1
  31. package/lib/contracts/repair-policy.d.ts +7 -0
  32. package/lib/contracts/repair-policy.js +17 -0
  33. package/lib/contracts/review.d.ts +12 -0
  34. package/lib/contracts/review.js +19 -0
  35. package/lib/control/harness.d.ts +2 -2
  36. package/lib/control/harness.js +2 -1
  37. package/lib/control/recovery.d.ts +26 -1
  38. package/lib/engine/engine.d.ts +41 -14
  39. package/lib/engine/engine.js +92 -8
  40. package/lib/engine/failure.d.ts +1 -1
  41. package/lib/engine/failure.js +1 -1
  42. package/lib/engine/property-recovery.d.ts +1 -0
  43. package/lib/engine/property-recovery.js +4 -1
  44. package/lib/engine/repair.d.ts +26 -0
  45. package/lib/engine/repair.js +83 -1
  46. package/lib/engine/runner.d.ts +7 -0
  47. package/lib/engine/runner.js +207 -34
  48. package/lib/explain/state.js +4 -2
  49. package/lib/index.d.ts +6 -4
  50. package/lib/index.js +6 -4
  51. package/lib/projections/stop.d.ts +1 -1
  52. package/lib/projections/stop.js +16 -0
  53. package/lib/storage/document.d.ts +48 -8
  54. package/lib/verification/acceptance.js +2 -1
  55. package/lib/verification/already-satisfied.d.ts +44 -0
  56. package/lib/verification/already-satisfied.js +49 -0
  57. package/lib/verification/checks.d.ts +28 -0
  58. package/lib/verification/checks.js +270 -22
  59. package/lib/verification/corpus.d.ts +50 -0
  60. package/lib/verification/gate-scope.d.ts +36 -0
  61. package/lib/verification/gate-scope.js +97 -0
  62. package/lib/verification/gates-at.d.ts +67 -0
  63. package/lib/verification/gates-at.js +173 -0
  64. package/lib/verification/gates.d.ts +5 -0
  65. package/lib/verification/gates.js +20 -5
  66. package/lib/verification/property.d.ts +9 -0
  67. package/lib/verification/property.js +11 -2
  68. package/lib/verification/self-check.js +12 -12
  69. package/package.json +1 -1
  70. package/schemas/contract-document.schema.json +161 -0
  71. package/schemas/execution-document.schema.json +134 -0
  72. package/schemas/run-event.schema.json +4 -0
  73. package/schemas/stop-diagnosis.schema.json +2 -0
@@ -3,9 +3,10 @@ import { roleBinding, executionDigest, HarnessError, WORKSPACE_OWNER, MAX_REPROD
3
3
  import { HARNESS_VERSION } from "../package.js";
4
4
  import { assertBudget, blockingBudget, budgetOverview, callAttribution, callBudgets, legacyGroupCall, workBudgets, } from "./budget.js";
5
5
  import { callDurationMs, callOwner, callRecords, ownerGone } from "./usage.js";
6
- import { failureFingerprint, repairPolicy } from "./failure.js";
6
+ import { MAX_NEUTRAL_FAILURES, failureFingerprint, repairPolicy } from "./failure.js";
7
7
  import { reusableImplementation } from "./reuse.js";
8
8
  import { uncoveredGates, unprovenReason } from "../verification/proof.js";
9
+ import { alreadySatisfiedVerdict, contractCheckGates } from "../verification/already-satisfied.js";
9
10
  import { contractDocuments, corpusBindingHolds, effectiveChecks, episodeBasis, orderPropertyProblems, } from "../verification/checks.js";
10
11
  import { afterFailure, afterSuccess, blocksClaim, closeIfBasisChanged, } from "./property-recovery.js";
11
12
  import { REQUIREMENT_GATE } from "../verification/requirements.js";
@@ -22,6 +23,16 @@ const contentDigest = (order) => digestOf({ ...order, source: { connector: order
22
23
  * решения неразделимы. Хост может только добавить запрет (`HostGuards`) и
23
24
  * дать основания (`BasisProvider`); снять отказ движка он не может.
24
25
  */
26
+ /**
27
+ * Отказ, который ничего не говорит о работе, бюджет попыток поручения не
28
+ * тратит: окружение или провайдер не дали исполнить или проверить её.
29
+ * Бесконечного цикла это не открывает: такие отказы держат выдачу
30
+ * (`repairPolicy.blocksQueue`), а повтор ограничен пределом одинаковых
31
+ * отпечатков и общим бюджетом восстановлений.
32
+ */
33
+ const uncounted = (kind, blocked) => blocked || kind === 'environment' || kind === 'provider-auth';
34
+ /** Поручение исчерпало предел незасчитанных отказов: повтор — только со сбросом. */
35
+ export const neutralExhausted = (w) => (w.neutralFailures ?? 0) >= MAX_NEUTRAL_FAILURES;
25
36
  export class Engine {
26
37
  host;
27
38
  policy;
@@ -768,6 +779,8 @@ export class Engine {
768
779
  return false;
769
780
  if (w.attempt >= this.policy.maxAttempts)
770
781
  return false;
782
+ if (neutralExhausted(w))
783
+ return false;
771
784
  const graph = tx.graph(w.graphId);
772
785
  // Согласование привязано к graph digest и к политике, под которой оно дано.
773
786
  if (!graph?.approval || graph.approval.digest !== graph.digest)
@@ -1193,6 +1206,45 @@ export class Engine {
1193
1206
  for (const phase of ['candidate', 'integration'])
1194
1207
  if (!last('review', phase, phase === 'candidate' ? a.candidateSha : sha, 'independent-review'))
1195
1208
  throw new HarnessError('REVIEW_REJECTED', `Нет независимого ревью: ${phase}`);
1209
+ // Попытка без изменений (кандидат — база) принимается только с
1210
+ // подтверждением reviewer обеих фаз, что приёмка уже выполнена.
1211
+ if (a.candidateSha === a.baseSha)
1212
+ for (const phase of ['candidate', 'integration']) {
1213
+ const decisive = a.evidence.findLast((e) => e.kind === 'review' &&
1214
+ e.phase === phase &&
1215
+ e.sha === (phase === 'candidate' ? a.candidateSha : sha) &&
1216
+ e.gate === 'independent-review' &&
1217
+ e.consumer === undefined &&
1218
+ (!a.checks || e.checks === a.checks.effective));
1219
+ const confirmed = decisive?.alreadySatisfied;
1220
+ // Правило повторяется целиком по записанному: область задачи,
1221
+ // договор поручения и основание по зависимости, сверенное с её
1222
+ // принятой попыткой.
1223
+ const basis = confirmed?.basis;
1224
+ const dependency = basis?.kind === 'dependency' &&
1225
+ tx
1226
+ .attempts(basis.workId)
1227
+ .some((x) => x.status === 'succeeded' &&
1228
+ x.candidateSha === basis.sha &&
1229
+ x.candidateSha !== x.baseSha &&
1230
+ tx.work(basis.workId)?.resultSha === x.integrationSha)
1231
+ ? basis
1232
+ : undefined;
1233
+ const repo = this.repository(a.repositoryId);
1234
+ const verdict = confirmed
1235
+ ? alreadySatisfiedVerdict({
1236
+ scope: repo.gates.filter((g) => (a.requiredGates ?? []).includes(g.id)),
1237
+ contractGates: contractCheckGates(contractDocuments(w.order, tx)),
1238
+ writeScope: w.order.writeScope,
1239
+ dependsOn: w.order.dependsOn,
1240
+ claim: { gates: confirmed.gates, evidence: confirmed.evidence },
1241
+ ...(dependency ? { dependency } : {}),
1242
+ })
1243
+ : undefined;
1244
+ if (!verdict?.ok)
1245
+ throw new HarnessError('REVIEW_REJECTED', `Изменений нет, а ревью ${phase} не подтвердило, что приёмка уже выполнена` +
1246
+ (verdict ? ': ' + verdict.problem : ''));
1247
+ }
1196
1248
  // Проверки потребителей — на integration, против интегрированного
1197
1249
  // результата: evidence потребителя закрывает только его проверку.
1198
1250
  for (const consumer of a.consumers ?? []) {
@@ -1283,9 +1335,13 @@ export class Engine {
1283
1335
  : undefined;
1284
1336
  const shown = prefix ? `${prefix}. ${error}` : error;
1285
1337
  const blocked = Boolean(options.blocked);
1338
+ const interrupted = Boolean(options.interrupted) && !a.integrationSha && !!w.approvedDigest;
1286
1339
  // Свойство отказа — только объявленное поручением: неизвестное
1287
1340
  // сохраняется для разбора, но счётчик эпизода не выбирает.
1288
1341
  const declared = new Set(w.order.properties ?? []);
1342
+ const pairs = (options.properties?.pairs ?? [])
1343
+ .filter((p) => !declared.has(p.property))
1344
+ .slice(0, 20);
1289
1345
  const bound = [...new Set((options.properties?.bound ?? []).filter((p) => declared.has(p)))];
1290
1346
  const unbound = [
1291
1347
  ...new Set([
@@ -1303,6 +1359,12 @@ export class Engine {
1303
1359
  finishedAt: iso(this.now()),
1304
1360
  ...(bound.length ? { properties: bound } : {}),
1305
1361
  ...(unbound.length ? { unboundProperties: unbound } : {}),
1362
+ ...(options.properties?.classes?.length
1363
+ ? { inputClasses: options.properties.classes.slice(0, 20) }
1364
+ : {}),
1365
+ // Пары по объявленным свойствам ведёт эпизод разбора, не пробел приёмки.
1366
+ ...(pairs.length ? { classified: pairs } : {}),
1367
+ ...(interrupted ? { interrupted: 'shutdown' } : {}),
1306
1368
  };
1307
1369
  tx.putAttempt(failed);
1308
1370
  const next = structuredClone(w);
@@ -1312,12 +1374,26 @@ export class Engine {
1312
1374
  this.recoveryEvent(tx, w, episode);
1313
1375
  tx.putWork({
1314
1376
  ...next,
1315
- status: 'failed',
1316
- failure: shown,
1317
- failureKind: kind,
1318
- failureFingerprint: fingerprint,
1377
+ // Прерванная остановкой работа не упала: она снова в очереди.
1378
+ ...(interrupted
1379
+ ? {
1380
+ status: 'ready',
1381
+ failure: undefined,
1382
+ failureKind: undefined,
1383
+ failureFingerprint: undefined,
1384
+ }
1385
+ : {
1386
+ status: 'failed',
1387
+ failure: shown,
1388
+ failureKind: kind,
1389
+ failureFingerprint: fingerprint,
1390
+ }),
1319
1391
  activeRunId: undefined,
1320
- attempt: blocked && w.attempt > 0 ? w.attempt - 1 : w.attempt,
1392
+ attempt: (interrupted || uncounted(kind, blocked)) && w.attempt > 0 ? w.attempt - 1 : w.attempt,
1393
+ // Отказ до работы (blocked) и штатная остановка предел не тратят.
1394
+ ...(uncounted(kind, blocked) && !blocked && !interrupted
1395
+ ? { neutralFailures: (w.neutralFailures ?? 0) + 1 }
1396
+ : {}),
1321
1397
  });
1322
1398
  tx.emit({
1323
1399
  type: 'attempt.failed',
@@ -1327,7 +1403,8 @@ export class Engine {
1327
1403
  attempt: a.attempt,
1328
1404
  payload: { failureKind: kind, message: shown.slice(0, 4000), blocked },
1329
1405
  });
1330
- if (repairPolicy[kind].blocksQueue) {
1406
+ // Остановка — не неисправность окружения: её пауза уже стоит.
1407
+ if (repairPolicy[kind].blocksQueue && !interrupted) {
1331
1408
  const control = tx.control();
1332
1409
  const failures = [...new Set([...(control.pauseFailures ?? []), kind])];
1333
1410
  tx.setControl({
@@ -1350,6 +1427,8 @@ export class Engine {
1350
1427
  runId: id,
1351
1428
  kind,
1352
1429
  blocked,
1430
+ /** Работа возвращена в очередь (штатная остановка); иначе — обычный отказ. */
1431
+ interrupted,
1353
1432
  ...(bound.length ? { properties: bound } : {}),
1354
1433
  ...(episode ? { recovery: { id: episode.id, status: episode.status } } : {}),
1355
1434
  };
@@ -2160,6 +2239,10 @@ export class Engine {
2160
2239
  failureKind: 'environment',
2161
2240
  failureFingerprint: fingerprint,
2162
2241
  activeRunId: undefined,
2242
+ // Владение истекло не по вине кода: попытка не засчитывается,
2243
+ // но предел незасчитанных отказов тратится.
2244
+ attempt: w.attempt > 0 ? w.attempt - 1 : w.attempt,
2245
+ neutralFailures: (w.neutralFailures ?? 0) + 1,
2163
2246
  });
2164
2247
  tx.emit({
2165
2248
  type: 'attempt.expired',
@@ -2261,7 +2344,7 @@ export class Engine {
2261
2344
  const w = tx.work(workId);
2262
2345
  if (!w)
2263
2346
  throw new HarnessError('NOT_FOUND', 'Поручение не найдено: ' + workId);
2264
- const exhausted = w.attempt >= this.policy.maxAttempts;
2347
+ const exhausted = w.attempt >= this.policy.maxAttempts || neutralExhausted(w);
2265
2348
  if (w.status === 'ready' && !exhausted)
2266
2349
  return { work: w, changed: false };
2267
2350
  if (!['failed', 'cancelled'].includes(w.status) && !(exhausted && !w.activeRunId))
@@ -2272,6 +2355,7 @@ export class Engine {
2272
2355
  ...w,
2273
2356
  status: w.approvedDigest ? 'ready' : 'draft',
2274
2357
  attempt: exhausted ? 0 : w.attempt,
2358
+ neutralFailures: exhausted ? undefined : w.neutralFailures,
2275
2359
  failure: undefined,
2276
2360
  failureKind: undefined,
2277
2361
  failureFingerprint: undefined,
@@ -1 +1 @@
1
- export { TaskFailure, failureFingerprint, failureKinds, repairPolicy, type FailureKind, type RepairRule, } from '../contracts/index.ts';
1
+ export { TaskFailure, failureFingerprint, failureKinds, repairPolicy, MAX_NEUTRAL_FAILURES, type FailureKind, type RepairRule, } from '../contracts/index.ts';
@@ -1,3 +1,3 @@
1
1
  // Таксономия отказов — часть договора (`contracts/failure.ts`): её читают и
2
2
  // хосты, в том числе в браузере, без загрузки исполнения.
3
- export { TaskFailure, failureFingerprint, failureKinds, repairPolicy, } from "../contracts/index.js";
3
+ export { TaskFailure, failureFingerprint, failureKinds, repairPolicy, MAX_NEUTRAL_FAILURES, } from "../contracts/index.js";
@@ -25,6 +25,7 @@ import type { AttemptState, PropertyRecovery, WorkState } from '../contracts/ind
25
25
  export declare const RECOVERY_THRESHOLD = 2;
26
26
  /** Поручение с незавершённым разбором автоматическая выдача пропускает. */
27
27
  export declare const blocksClaim: (w: Pick<WorkState, "recovery">) => boolean;
28
+ export declare const isNeutral: (a: AttemptState) => boolean | undefined;
28
29
  /**
29
30
  * Последовательные содержательные отказы по свойству в текущем эпизоде,
30
31
  * от последней попытки к первой.
@@ -38,10 +38,13 @@ const neutral = [
38
38
  // Ошибка формата ответа reviewer о свойстве ничего не говорит.
39
39
  'review-protocol',
40
40
  'empty-result',
41
+ // Незавершённая работа свойство не проверяла.
42
+ 'unfinished',
43
+ 'needs-lead',
41
44
  'scope-violation',
42
45
  ];
43
46
  const substantive = (a) => a.failureKind === 'gate' || a.failureKind === 'review';
44
- const isNeutral = (a) => a.blocked || a.status === 'expired' || (a.failureKind && neutral.includes(a.failureKind));
47
+ export const isNeutral = (a) => a.blocked || a.status === 'expired' || (a.failureKind && neutral.includes(a.failureKind));
45
48
  /** Свойство прошло проверку в попытке: его gate зелёный, отказа по нему нет. */
46
49
  function verifiedPass(a, property) {
47
50
  if (a.properties?.includes(property))
@@ -12,7 +12,33 @@ export type RepairDecision = {
12
12
  workId?: string;
13
13
  kind?: FailureKind;
14
14
  reason: string;
15
+ /** Остановка — пробел приёмки, а не исчерпанный бюджет (`acceptanceGap`). */
16
+ gap?: AcceptanceGap;
15
17
  };
18
+ /** Свойство или класс входов, по которому ревью отклонило подряд, и эти попытки. */
19
+ export interface AcceptanceGap {
20
+ /**
21
+ * `pair:<свойство>␀<класс>` — одно и то же необъявленное свойство с тем же
22
+ * классом входов; `property:<id>` — только свойство; `class:<текст>` —
23
+ * только класс входов.
24
+ */
25
+ key: string;
26
+ runs: string[];
27
+ }
28
+ /**
29
+ * Пробел приёмки: последний отказ поручения — отказ ревью, и
30
+ * {@link RECOVERY_THRESHOLD} содержательных отказа ревью подряд, считая его,
31
+ * назвали одно и то же свойство или
32
+ * класс входов. Каждая попытка закрывала конкретный пример, ревью находило
33
+ * следующий из того же класса: зелёные gates этот класс не проверяют, и
34
+ * третья слепая попытка ничего не даст. Объявленные свойства сюда не входят —
35
+ * их ведёт эпизод разбора (`property-recovery.ts`).
36
+ *
37
+ * Счёт — как у эпизода: инфраструктурный отказ не увеличивает и не обнуляет
38
+ * его; прохождение, отказ gate или ревью без этого названия, смена основания
39
+ * поручения (постановки или контрактов) обрывают серию.
40
+ */
41
+ export declare function acceptanceGap(view: ExecutionView, work: WorkState): AcceptanceGap | undefined;
16
42
  /**
17
43
  * Что делать с упавшим поручением группы.
18
44
  *
@@ -1,4 +1,67 @@
1
- import { repairPolicy } from "./failure.js";
1
+ import { MAX_NEUTRAL_FAILURES, repairPolicy } from "./failure.js";
2
+ import { RECOVERY_THRESHOLD, isNeutral } from "./property-recovery.js";
3
+ const normalized = (c) => c.trim().toLowerCase().replace(/\s+/g, ' ');
4
+ /** Что отклонившее ревью назвало в попытке: пары, необъявленные свойства и классы входов. */
5
+ function namedBy(a) {
6
+ return new Set([
7
+ ...(a.classified ?? []).map((p) => 'pair:' + p.property + '\u0000' + normalized(p.inputClass)),
8
+ ...(a.unboundProperties ?? []).map((p) => 'property:' + p),
9
+ ...(a.inputClasses ?? []).map((c) => 'class:' + normalized(c)),
10
+ ]);
11
+ }
12
+ /**
13
+ * Сколько отказов подряд нужно для остановки. Одно и то же свойство с тем же
14
+ * классом — один и тот же дефект: двух хватает. Только свойство или только
15
+ * класс (общий класс вроде «граничные случаи» у двух разных дефектов) — слабее,
16
+ * ложная остановка дороже лишнего повтора: нужно три.
17
+ */
18
+ const thresholdOf = (key) => key.startsWith('pair:') ? RECOVERY_THRESHOLD : RECOVERY_THRESHOLD + 1;
19
+ /**
20
+ * Пробел приёмки: последний отказ поручения — отказ ревью, и
21
+ * {@link RECOVERY_THRESHOLD} содержательных отказа ревью подряд, считая его,
22
+ * назвали одно и то же свойство или
23
+ * класс входов. Каждая попытка закрывала конкретный пример, ревью находило
24
+ * следующий из того же класса: зелёные gates этот класс не проверяют, и
25
+ * третья слепая попытка ничего не даст. Объявленные свойства сюда не входят —
26
+ * их ведёт эпизод разбора (`property-recovery.ts`).
27
+ *
28
+ * Счёт — как у эпизода: инфраструктурный отказ не увеличивает и не обнуляет
29
+ * его; прохождение, отказ gate или ревью без этого названия, смена основания
30
+ * поручения (постановки или контрактов) обрывают серию.
31
+ */
32
+ export function acceptanceGap(view, work) {
33
+ const own = view.attempts(work.id).filter((a) => a.status !== 'active');
34
+ const last = own.at(-1);
35
+ if (!last || last.status !== 'failed' || last.failureKind !== 'review')
36
+ return undefined;
37
+ for (const key of namedBy(last)) {
38
+ const runs = [];
39
+ const threshold = thresholdOf(key);
40
+ for (let i = own.length - 1; i >= 0 && runs.length < threshold; i--) {
41
+ const a = own[i];
42
+ if (a.episodeBasis !== last.episodeBasis)
43
+ break;
44
+ if (isNeutral(a))
45
+ continue;
46
+ if (a.status !== 'failed' || a.failureKind !== 'review' || !namedBy(a).has(key))
47
+ break;
48
+ runs.push(a.id);
49
+ }
50
+ if (runs.length >= threshold)
51
+ return { key, runs: runs.reverse() };
52
+ }
53
+ return undefined;
54
+ }
55
+ /** Предмет серии словами: свойство и класс, только свойство или только класс. */
56
+ function gapSubject(key) {
57
+ const [kind, rest] = [key.slice(0, key.indexOf(':')), key.slice(key.indexOf(':') + 1)];
58
+ const clip = (s) => s.slice(0, 200);
59
+ if (kind === 'pair') {
60
+ const [property, inputClass] = rest.split('\u0000');
61
+ return `свойству «${clip(property)}» с классом входов «${clip(inputClass)}»`;
62
+ }
63
+ return kind === 'property' ? `свойству «${clip(rest)}»` : `классу входов «${clip(rest)}»`;
64
+ }
2
65
  /**
3
66
  * Что делать с упавшим поручением группы.
4
67
  *
@@ -58,6 +121,25 @@ export function repairDecision(view, works, options) {
58
121
  kind,
59
122
  reason: `${work.id}: исчерпан бюджет попыток поручения. Устраните причину и повторите со сбросом, указав его.${detail}`,
60
123
  };
124
+ // Отказы окружения попытку не тратят, но их предел — свой.
125
+ if ((work.neutralFailures ?? 0) >= MAX_NEUTRAL_FAILURES)
126
+ return {
127
+ action: 'stop',
128
+ workId: work.id,
129
+ kind,
130
+ reason: `${work.id}: ${work.neutralFailures} отказов окружения или провайдера после начала работы — предел ${MAX_NEUTRAL_FAILURES}. Это неисправность среды; устраните её и повторите со сбросом, указав его.${detail}`,
131
+ };
132
+ // Тексты отказов ревью разные, и предел одинаковых отпечатков их не
133
+ // ловит; одно и то же нарушенное свойство или класс входов — ловит.
134
+ const gap = acceptanceGap(view, work);
135
+ if (gap)
136
+ return {
137
+ action: 'stop',
138
+ workId: work.id,
139
+ kind,
140
+ gap,
141
+ reason: `${work.id}: пробел приёмки — ревью ${gap.runs.length} раза подряд отклонило по одному и тому же ${gapSubject(gap.key)} при зелёных проверках (попытки ${gap.runs.join(', ')}). Третья слепая попытка не выдаётся: приёмка этот класс не проверяет. Решение ведущего — закрепить класс проверкой (корпус, эталонный набор, свойство в договоре проверки) или разделить задачу.${detail}`,
142
+ };
61
143
  const fingerprint = work.failureFingerprint;
62
144
  const identical = fingerprint
63
145
  ? view.attempts(work.id).filter((a) => a.failureFingerprint === fingerprint).length
@@ -243,6 +243,13 @@ export declare class Runner {
243
243
  * команды — засчитывается всем резервом, записанным до запуска, а его
244
244
  * пережившие процессы останавливаются (R3-F01).
245
245
  */
246
+ /**
247
+ * Решение по подтверждению «приёмка уже выполнена» (`alreadySatisfiedVerdict`).
248
+ * Основание по зависимости controller ищет сам: зависимость поручения,
249
+ * чей принятый результат входит в проверяемый SHA, а её собственный
250
+ * кандидат (база → кандидат) изменил файлы области записи поручения.
251
+ */
252
+ private alreadySatisfied;
246
253
  private probeSpent;
247
254
  private reviewOnce;
248
255
  /**