devcontour-harness 0.1.0-alpha.6 → 0.1.0-alpha.7

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.
@@ -136,12 +136,76 @@ export const propertyEvidenceSchema = z.strictObject({
136
136
  .optional(),
137
137
  coverage: z.array(z.string().max(1000)).optional(),
138
138
  });
139
- /** Что reviewer действительно выполнил — по событиям runtime, не по словам модели. */
139
+ /** Происхождение подтверждённой команды: событие runtime или запуск probe controller. */
140
+ export const inspectionOriginSchema = z.discriminatedUnion('type', [
141
+ z.strictObject({
142
+ type: z.literal('runtime'),
143
+ /** Имя runtime reviewer (`codex`, `claude`, …). */
144
+ runtime: z.string().min(1).max(100),
145
+ /** ID item в потоке событий runtime; `null` — runtime его не дал. */
146
+ item: z.string().min(1).max(200).nullable(),
147
+ }),
148
+ z.strictObject({
149
+ type: z.literal('controller'),
150
+ /** runId запуска probe в журнале controller (не runId попытки). */
151
+ runId: z.string().min(1).max(200),
152
+ }),
153
+ ]);
154
+ /**
155
+ * Что reviewer действительно выполнил — по событиям runtime и журналу
156
+ * проверок controller одного вызова reviewer, не по словам модели
157
+ * (docs/review-probes.md). Поля после `reason` появились в alpha.7 и
158
+ * необязательны: записи прежних версий читаются как есть.
159
+ */
140
160
  export const reviewInspectionSchema = z.strictObject({
141
161
  mode: z.enum(['commands', 'diff-only']),
142
- source: z.enum(['codex-events', 'unavailable']),
143
- commands: z.array(z.strictObject({ command: z.string(), exitCode: z.number().int(), output: z.string() })),
162
+ source: z.enum(['codex-events', 'unavailable', 'controller-probes', 'runtime-and-controller']),
163
+ commands: z.array(z.strictObject({
164
+ command: z.string(),
165
+ /** `null` — код неизвестен: таймаут/отмена после старта, старая проекция без кода. */
166
+ exitCode: z.number().int().nullable(),
167
+ output: z.string(),
168
+ origins: z.array(inspectionOriginSchema).min(1).max(8).optional(),
169
+ /** Исход: `completed`/`failed` — runtime; остальные — статус probe controller. */
170
+ status: z.enum(['completed', 'passed', 'failed', 'timeout', 'cancelled', 'error']).optional(),
171
+ /** Неуспех не в проверяемом коде: среда или отчёт (как у probe). */
172
+ failure: z.enum(['environment', 'report']).optional(),
173
+ /** ID объявленного gate, запущенного через probe. */
174
+ gate: z.string().min(1).max(200).optional(),
175
+ note: z.string().max(2000).optional(),
176
+ })),
144
177
  reason: z.string().nullable(),
178
+ /** Вызов reviewer, которому принадлежит итог. */
179
+ context: z
180
+ .strictObject({
181
+ /** ID попытки harness (runId evidence). */
182
+ attempt: z.string().min(1).max(200),
183
+ phase: z.enum(['candidate', 'integration']),
184
+ sha: gitShaSchema,
185
+ call: z.number().int().positive(),
186
+ })
187
+ .optional(),
188
+ /** Число всех подтверждённых операций, в том числе не вошедших в `commands`. */
189
+ total: z.number().int().min(0).optional(),
190
+ /** Записи журнала, не засчитанные выполнением: почему (не более 100). */
191
+ excluded: z
192
+ .array(z.strictObject({
193
+ runId: z.string().min(1).max(200),
194
+ status: z.string().max(40).nullable(),
195
+ reason: z.enum([
196
+ 'not-started',
197
+ 'legacy-unconfirmed',
198
+ 'foreign-scope',
199
+ 'unknown-status',
200
+ 'unfinished',
201
+ 'abandoned',
202
+ ]),
203
+ }))
204
+ .max(100)
205
+ .optional(),
206
+ excludedTotal: z.number().int().min(0).optional(),
207
+ /** Строки журнала без формы исхода (битый JSON, `{}`): не запуски. */
208
+ invalid: z.number().int().min(0).optional(),
145
209
  });
146
210
  export const evidenceFindingSchema = z.strictObject({
147
211
  severity: z.string().min(1).max(40),
@@ -2812,13 +2812,61 @@ export declare const publishedSchemas: {
2812
2812
  source: z.ZodEnum<{
2813
2813
  "codex-events": "codex-events";
2814
2814
  unavailable: "unavailable";
2815
+ "controller-probes": "controller-probes";
2816
+ "runtime-and-controller": "runtime-and-controller";
2815
2817
  }>;
2816
2818
  commands: z.ZodArray<z.ZodObject<{
2817
2819
  command: z.ZodString;
2818
- exitCode: z.ZodNumber;
2820
+ exitCode: z.ZodNullable<z.ZodNumber>;
2819
2821
  output: z.ZodString;
2822
+ origins: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
2823
+ type: z.ZodLiteral<"runtime">;
2824
+ runtime: z.ZodString;
2825
+ item: z.ZodNullable<z.ZodString>;
2826
+ }, z.core.$strict>, z.ZodObject<{
2827
+ type: z.ZodLiteral<"controller">;
2828
+ runId: z.ZodString;
2829
+ }, z.core.$strict>], "type">>>;
2830
+ status: z.ZodOptional<z.ZodEnum<{
2831
+ error: "error";
2832
+ passed: "passed";
2833
+ failed: "failed";
2834
+ cancelled: "cancelled";
2835
+ completed: "completed";
2836
+ timeout: "timeout";
2837
+ }>>;
2838
+ failure: z.ZodOptional<z.ZodEnum<{
2839
+ report: "report";
2840
+ environment: "environment";
2841
+ }>>;
2842
+ gate: z.ZodOptional<z.ZodString>;
2843
+ note: z.ZodOptional<z.ZodString>;
2820
2844
  }, z.core.$strict>>;
2821
2845
  reason: z.ZodNullable<z.ZodString>;
2846
+ context: z.ZodOptional<z.ZodObject<{
2847
+ attempt: z.ZodString;
2848
+ phase: z.ZodEnum<{
2849
+ candidate: "candidate";
2850
+ integration: "integration";
2851
+ }>;
2852
+ sha: z.ZodString;
2853
+ call: z.ZodNumber;
2854
+ }, z.core.$strict>>;
2855
+ total: z.ZodOptional<z.ZodNumber>;
2856
+ excluded: z.ZodOptional<z.ZodArray<z.ZodObject<{
2857
+ runId: z.ZodString;
2858
+ status: z.ZodNullable<z.ZodString>;
2859
+ reason: z.ZodEnum<{
2860
+ "not-started": "not-started";
2861
+ "legacy-unconfirmed": "legacy-unconfirmed";
2862
+ "foreign-scope": "foreign-scope";
2863
+ "unknown-status": "unknown-status";
2864
+ unfinished: "unfinished";
2865
+ abandoned: "abandoned";
2866
+ }>;
2867
+ }, z.core.$strict>>>;
2868
+ excludedTotal: z.ZodOptional<z.ZodNumber>;
2869
+ invalid: z.ZodOptional<z.ZodNumber>;
2822
2870
  }, z.core.$strict>>;
2823
2871
  findings: z.ZodOptional<z.ZodArray<z.ZodObject<{
2824
2872
  severity: z.ZodString;
@@ -3,6 +3,7 @@ import { mkdir, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { HarnessError, WORKSPACE_OWNER, contextDigest, findingId, mcpEnvironment, reviewIssues, reviewResultSchema, selectReviewer, } from "../contracts/index.js";
5
5
  import { reviewOutputSchema } from "../agents/schemas.js";
6
+ import { redactInspection } from "../agents/inspect.js";
6
7
  import { executionEnvironment } from "../environment/lifecycle.js";
7
8
  import { redactDeep } from "../environment/redaction.js";
8
9
  import { TaskFailure } from "../engine/failure.js";
@@ -165,11 +166,14 @@ export async function technicalReview(engine, policy, adapters, input) {
165
166
  };
166
167
  // Сохраняемые представления маскируются полным окружением вызова: сырой
167
168
  // структурированный ответ адаптера может нести значение секрета профиля.
168
- await writeFile(record.artifact, JSON.stringify(redactDeep({
169
- response: parsed.success ? parsed.data : null,
170
- issues,
171
- inspection: response.inspection ?? null,
172
- }, execution.redact), null, 2));
169
+ const artifact = redactDeep({ response: parsed.success ? parsed.data : null, issues, inspection: null }, execution.redact);
170
+ // Inspection маскируется по полям: служебные значения не текст reviewer.
171
+ await writeFile(record.artifact, JSON.stringify({
172
+ ...artifact,
173
+ inspection: response.inspection
174
+ ? redactInspection(response.inspection, execution.redact)
175
+ : null,
176
+ }, null, 2));
173
177
  // Снимок, который видел reviewer, хранится по digest: согласование этого
174
178
  // раунда делает его действующим контекстом графа.
175
179
  if (context && contextKey)
@@ -47,7 +47,7 @@ export declare function scopeRecords(c: BudgetContext, view: ExecutionView, reco
47
47
  model: string | null;
48
48
  runtimeVersion: string | null;
49
49
  startedAt: string;
50
- outcome: "error" | "active" | "returned" | "abandoned";
50
+ outcome: "error" | "active" | "abandoned" | "returned";
51
51
  promptBytes: number;
52
52
  promptDigest: string;
53
53
  usage: {
@@ -133,7 +133,7 @@ export declare function workBudgets(c: BudgetContext, view: ExecutionView, workI
133
133
  model: string | null;
134
134
  runtimeVersion: string | null;
135
135
  startedAt: string;
136
- outcome: "error" | "active" | "returned" | "abandoned";
136
+ outcome: "error" | "active" | "abandoned" | "returned";
137
137
  promptBytes: number;
138
138
  promptDigest: string;
139
139
  usage: {
@@ -227,7 +227,7 @@ export declare function callBudgets(c: BudgetContext, view: ExecutionView, meta:
227
227
  model: string | null;
228
228
  runtimeVersion: string | null;
229
229
  startedAt: string;
230
- outcome: "error" | "active" | "returned" | "abandoned";
230
+ outcome: "error" | "active" | "abandoned" | "returned";
231
231
  promptBytes: number;
232
232
  promptDigest: string;
233
233
  usage: {
@@ -492,13 +492,39 @@ export declare class Engine {
492
492
  consumer?: string | undefined;
493
493
  inspection?: {
494
494
  mode: "commands" | "diff-only";
495
- source: "codex-events" | "unavailable";
495
+ source: "codex-events" | "unavailable" | "controller-probes" | "runtime-and-controller";
496
496
  commands: {
497
497
  command: string;
498
- exitCode: number;
498
+ exitCode: number | null;
499
499
  output: string;
500
+ origins?: ({
501
+ type: "runtime";
502
+ runtime: string;
503
+ item: string | null;
504
+ } | {
505
+ type: "controller";
506
+ runId: string;
507
+ })[] | undefined;
508
+ status?: "error" | "passed" | "failed" | "cancelled" | "completed" | "timeout" | undefined;
509
+ failure?: "report" | "environment" | undefined;
510
+ gate?: string | undefined;
511
+ note?: string | undefined;
500
512
  }[];
501
513
  reason: string | null;
514
+ context?: {
515
+ attempt: string;
516
+ phase: "candidate" | "integration";
517
+ sha: string;
518
+ call: number;
519
+ } | undefined;
520
+ total?: number | undefined;
521
+ excluded?: {
522
+ runId: string;
523
+ status: string | null;
524
+ reason: "not-started" | "legacy-unconfirmed" | "foreign-scope" | "unknown-status" | "unfinished" | "abandoned";
525
+ }[] | undefined;
526
+ excludedTotal?: number | undefined;
527
+ invalid?: number | undefined;
502
528
  } | undefined;
503
529
  findings?: {
504
530
  severity: string;
@@ -8,9 +8,9 @@ import { git } from "../environment/process.js";
8
8
  import { executionEnvironment, runSteps, withEnvironment } from "../environment/lifecycle.js";
9
9
  import { redactDeep } from "../environment/redaction.js";
10
10
  import { implementationOutputSchema, reviewOutputSchema } from "../agents/schemas.js";
11
- import { unobservedReview } from "../agents/inspect.js";
11
+ import { reviewInspection } from "../agents/inspect.js";
12
12
  import { reviewerRunsChecks } from "../agents/cli-adapter.js";
13
- import { gateProbePrompt, observeReproductions, probeCommand, probeGates, probeLimits, probeRecords, recoverProbeLog, probeTool, } from "../agents/review-probe.js";
13
+ import { gateProbePrompt, observeReproductions, probeCommand, probeGates, probeLimits, probeRecord, readProbeJournal, recoverProbeLog, probeTool, } from "../agents/review-probe.js";
14
14
  import { runGate } from "../verification/gates.js";
15
15
  import { assertDependencies, dependencyFreshness, removeSnapshots, snapshotDependencies, } from "../verification/dependencies.js";
16
16
  import { CleanupFailure, cleanupEnvironment } from "../environment/lifecycle.js";
@@ -1148,6 +1148,8 @@ export class Runner {
1148
1148
  * переменных окружения CLI передаёт серверу по имени, на диск они не пишутся.
1149
1149
  */
1150
1150
  async reviewProbe(attempt, work, cwd, dir, phase, sha, gates,
1151
+ /** Номер вызова reviewer логического ревью: записывается в каждый запуск probe. */
1152
+ call,
1151
1153
  /**
1152
1154
  * Повтор reviewer того же логического ревью: срок и остаток бюджета
1153
1155
  * проверок переходят от первого вызова, а не выдаются заново.
@@ -1186,6 +1188,7 @@ export class Runner {
1186
1188
  gates: probeGates(gates),
1187
1189
  phase,
1188
1190
  sha,
1191
+ scope: { attempt: attempt.id, phase, sha, call },
1189
1192
  artifacts: join(dir, 'probe-runs'),
1190
1193
  };
1191
1194
  const specPath = join(dir, 'probe.json');
@@ -1316,7 +1319,7 @@ export class Runner {
1316
1319
  }
1317
1320
  const callDir = call === 1 ? dir : join(dir, `call-${call}`);
1318
1321
  await mkdir(callDir, { recursive: true });
1319
- const probe = await this.reviewProbe(attempt, work, cwd, callDir, phase, sha, gates, shared);
1322
+ const probe = await this.reviewProbe(attempt, work, cwd, callDir, phase, sha, gates, call, shared);
1320
1323
  const response = await this.call(this.adapter(attempt.reviewer), {
1321
1324
  task: 'review-code',
1322
1325
  attempt,
@@ -1357,9 +1360,17 @@ export class Runner {
1357
1360
  }
1358
1361
  : checkCodeReview(response.output, history, { runId: attempt.id, phase, sha }, { legacy });
1359
1362
  const result = verdict.result;
1360
- const inspection = response.inspection ?? unobservedReview();
1361
1363
  // Проверки reviewer — по журналу controller, а не по самоотчёту модели.
1362
- const probes = probe ? await probeRecords(probe.log) : undefined;
1364
+ const journal = probe ? await readProbeJournal(probe.log) : undefined;
1365
+ const probes = journal?.records.map(probeRecord);
1366
+ // Один итог вызова: события runtime и журнал controller этого вызова,
1367
+ // уже замаскированный; он же — в review.json и во всех ветках evidence.
1368
+ const inspection = reviewInspection({
1369
+ runtime: response.inspection,
1370
+ journal,
1371
+ context: { attempt: attempt.id, phase, sha, call },
1372
+ redact,
1373
+ });
1363
1374
  const protocol = !clean
1364
1375
  ? 'unsafe'
1365
1376
  : verdict.valid
@@ -1388,13 +1399,10 @@ export class Runner {
1388
1399
  ...(result ? {} : { raw: response.output ?? null }),
1389
1400
  ...(probes ? { probes } : {}),
1390
1401
  }, redact);
1391
- // Служебные значения записи — перечисления контура, а не текст reviewer:
1392
- // короткий секрет, совпавший с их частью, их не маскирует.
1393
- record.inspection = {
1394
- ...record.inspection,
1395
- mode: inspection.mode,
1396
- source: inspection.source,
1397
- };
1402
+ // Итог уже замаскирован до обрезки; его перечисления и идентификаторы
1403
+ // controller — не текст reviewer: короткий секрет, совпавший с их
1404
+ // частью, их не маскирует.
1405
+ record.inspection = inspection;
1398
1406
  if (result)
1399
1407
  record.findings = record.findings.map((f, i) => ({
1400
1408
  ...f,
@@ -14,6 +14,14 @@ export declare function command(argv: string[], cwd: string, options?: {
14
14
  env?: NodeJS.ProcessEnv;
15
15
  redact?: Redactor;
16
16
  onOutput?: (stream: 'stdout' | 'stderr', text: string) => void;
17
+ /**
18
+ * Немаскированный stdout — только в памяти вызывающего, для разбора
19
+ * структуры (события runtime): маска по всему потоку портила бы и
20
+ * служебные строки JSON. Сохранять его нельзя.
21
+ */
22
+ onRawStdout?: (text: string) => void;
23
+ /** Процесс запущен (событие `spawn`): до него команда не исполнялась. */
24
+ onSpawn?: () => void;
17
25
  /**
18
26
  * Владеть всеми потомками, включая сменивших группу или сессию: после
19
27
  * завершения они останавливаются, и результат не возвращается, пока
@@ -28,6 +28,8 @@ export async function command(argv, cwd, options = {}) {
28
28
  detached: process.platform !== 'win32',
29
29
  stdio: ['pipe', 'pipe', 'pipe'],
30
30
  });
31
+ if (options.onSpawn)
32
+ child.once('spawn', options.onSpawn);
31
33
  let stdout = '', stderr = '', timedOut = false, killTimer;
32
34
  const stdoutDecoder = new StringDecoder('utf8'), stderrDecoder = new StringDecoder('utf8');
33
35
  const maskOut = outputRedactor(options.redact), maskErr = outputRedactor(options.redact);
@@ -49,7 +51,9 @@ export async function command(argv, cwd, options = {}) {
49
51
  };
50
52
  child.stdout.on('data', (d) => {
51
53
  lastOutputAt = new Date().toISOString();
52
- append('stdout', maskOut(stdoutDecoder.write(d)));
54
+ const text = stdoutDecoder.write(d);
55
+ options.onRawStdout?.(text);
56
+ append('stdout', maskOut(text));
53
57
  });
54
58
  child.stderr.on('data', (d) => {
55
59
  lastOutputAt = new Date().toISOString();
@@ -152,7 +156,9 @@ export async function command(argv, cwd, options = {}) {
152
156
  child.stderr.destroy();
153
157
  }
154
158
  }
155
- append('stdout', maskOut(stdoutDecoder.end(), true));
159
+ const rest = stdoutDecoder.end();
160
+ options.onRawStdout?.(rest);
161
+ append('stdout', maskOut(rest, true));
156
162
  append('stderr', maskErr(stderrDecoder.end(), true));
157
163
  if (outputError) {
158
164
  reject(outputError);
@@ -490,13 +490,39 @@ export declare class DocumentView {
490
490
  consumer?: string | undefined;
491
491
  inspection?: {
492
492
  mode: "commands" | "diff-only";
493
- source: "codex-events" | "unavailable";
493
+ source: "codex-events" | "unavailable" | "controller-probes" | "runtime-and-controller";
494
494
  commands: {
495
495
  command: string;
496
- exitCode: number;
496
+ exitCode: number | null;
497
497
  output: string;
498
+ origins?: ({
499
+ type: "runtime";
500
+ runtime: string;
501
+ item: string | null;
502
+ } | {
503
+ type: "controller";
504
+ runId: string;
505
+ })[] | undefined;
506
+ status?: "error" | "passed" | "failed" | "cancelled" | "completed" | "timeout" | undefined;
507
+ failure?: "report" | "environment" | undefined;
508
+ gate?: string | undefined;
509
+ note?: string | undefined;
498
510
  }[];
499
511
  reason: string | null;
512
+ context?: {
513
+ attempt: string;
514
+ phase: "candidate" | "integration";
515
+ sha: string;
516
+ call: number;
517
+ } | undefined;
518
+ total?: number | undefined;
519
+ excluded?: {
520
+ runId: string;
521
+ status: string | null;
522
+ reason: "not-started" | "legacy-unconfirmed" | "foreign-scope" | "unknown-status" | "unfinished" | "abandoned";
523
+ }[] | undefined;
524
+ excludedTotal?: number | undefined;
525
+ invalid?: number | undefined;
500
526
  } | undefined;
501
527
  findings?: {
502
528
  severity: string;
@@ -755,13 +781,39 @@ export declare class DocumentView {
755
781
  consumer?: string | undefined;
756
782
  inspection?: {
757
783
  mode: "commands" | "diff-only";
758
- source: "codex-events" | "unavailable";
784
+ source: "codex-events" | "unavailable" | "controller-probes" | "runtime-and-controller";
759
785
  commands: {
760
786
  command: string;
761
- exitCode: number;
787
+ exitCode: number | null;
762
788
  output: string;
789
+ origins?: ({
790
+ type: "runtime";
791
+ runtime: string;
792
+ item: string | null;
793
+ } | {
794
+ type: "controller";
795
+ runId: string;
796
+ })[] | undefined;
797
+ status?: "error" | "passed" | "failed" | "cancelled" | "completed" | "timeout" | undefined;
798
+ failure?: "report" | "environment" | undefined;
799
+ gate?: string | undefined;
800
+ note?: string | undefined;
763
801
  }[];
764
802
  reason: string | null;
803
+ context?: {
804
+ attempt: string;
805
+ phase: "candidate" | "integration";
806
+ sha: string;
807
+ call: number;
808
+ } | undefined;
809
+ total?: number | undefined;
810
+ excluded?: {
811
+ runId: string;
812
+ status: string | null;
813
+ reason: "not-started" | "legacy-unconfirmed" | "foreign-scope" | "unknown-status" | "unfinished" | "abandoned";
814
+ }[] | undefined;
815
+ excludedTotal?: number | undefined;
816
+ invalid?: number | undefined;
765
817
  } | undefined;
766
818
  findings?: {
767
819
  severity: string;
@@ -79,14 +79,36 @@ export declare function runCheck(h: {
79
79
  readable: string[];
80
80
  controller: string[];
81
81
  settingsDir: string;
82
+ /**
83
+ * Запуск проверки подтверждён: без песочницы — порождён её процесс, в
84
+ * песочнице — обёртка создала метку старта перед exec. Запуск самой
85
+ * обёртки песочницы запуском проверки не считается. Вызывается не более
86
+ * одного раза, в том числе когда проверка затем завершилась исключением.
87
+ */
88
+ onStart?: () => void;
82
89
  }): Promise<{
83
90
  attempts: number;
91
+ started: boolean;
84
92
  code: number;
85
93
  stdout: string;
86
94
  stderr: string;
87
95
  timedOut: boolean;
88
96
  diagnostics: import("../contracts/execution.ts").RuntimeDiagnostics;
89
97
  }>;
98
+ /** Исход проверки, завершившейся до исключения (например, отказа уборки), если он есть. */
99
+ export declare function completedCheck(error: unknown): {
100
+ attempts: number;
101
+ started: boolean;
102
+ code: number;
103
+ stdout: string;
104
+ stderr: string;
105
+ timedOut: boolean;
106
+ diagnostics: import("../contracts/execution.ts").RuntimeDiagnostics;
107
+ } | undefined;
108
+ /** Уборка каталогов проверки; тест подменяет её, чтобы проверить отказ после исхода. */
109
+ export declare const checkCleanup: {
110
+ remove: (dir: string) => Promise<void>;
111
+ };
90
112
  /** Проверка механизма песочницы; тест подменяет её, чтобы проверить отказ. */
91
113
  export declare const isolationBackend: {
92
114
  check: typeof isolationSupport;
@@ -251,7 +251,14 @@ export async function prepareReportPath(root, path, escape) {
251
251
  export async function runCheck(
252
252
  // Нужна только политика изоляции: проверки ревьюера исполняются вне сервера.
253
253
  h, options) {
254
- let argv = options.argv, env = options.env, started;
254
+ let launched = false;
255
+ const confirm = () => {
256
+ if (launched)
257
+ return;
258
+ launched = true;
259
+ options.onStart?.();
260
+ };
261
+ let argv = options.argv, env = options.env, started, result, attempts = 1;
255
262
  const scratch = await realpath(await mkdtemp(join(tmpdir(), 'dc-gate-')));
256
263
  // Метка старта — вне TMPDIR команды: проверка, чистящая свой временный
257
264
  // каталог, не должна стирать признак того, что она уже начиналась.
@@ -275,39 +282,73 @@ h, options) {
275
282
  argv = wrapped.argv;
276
283
  env = { ...env, ...wrapped.env };
277
284
  }
278
- const execute = () => command(argv, options.cwd, {
279
- signal: options.signal,
280
- timeoutMs: options.timeoutMs,
281
- env,
282
- redact: options.redact,
283
- // Отсоединившийся потомок проверки не переживает её: и при успехе,
284
- // и по таймауту, отмене или падению.
285
- // Каталоги проверки принадлежат только ей: процесс, запущенный в них
286
- // во время проверки, — её потомок, даже без метки и родства.
287
- contain: { dirs: [...options.write, scratch, marks] },
288
- });
289
- let result = await execute(), attempts = 1;
290
- // sandbox-runtime ищет shell через `which` с таймаутом в секунду и под
291
- // нагрузкой отказывает до запуска команды. Повтор допустим, только если
292
- // команда точно не начиналась: метки старта нет.
293
- for (; attempts < 3 && sandboxNotStarted(result, started); attempts++) {
294
- await new Promise((r) => setTimeout(r, 500 * attempts));
285
+ const execute = () => {
286
+ // Уже отменённый вызов не запускается: слушатель отмены на отменённом
287
+ // сигнале не срабатывает, и команда шла бы до таймаута.
288
+ if (options.signal.aborted)
289
+ throw Object.assign(new Error('Проверка отменена до запуска'), { name: 'AbortError' });
290
+ return command(argv, options.cwd, {
291
+ signal: options.signal,
292
+ ...(started === undefined ? { onSpawn: confirm } : {}),
293
+ timeoutMs: options.timeoutMs,
294
+ env,
295
+ redact: options.redact,
296
+ // Отсоединившийся потомок проверки не переживает её: и при успехе,
297
+ // и по таймауту, отмене или падению.
298
+ // Каталоги проверки принадлежат только ей: процесс, запущенный в них
299
+ // во время проверки, — её потомок, даже без метки и родства.
300
+ contain: { dirs: [...options.write, scratch, marks] },
301
+ });
302
+ };
303
+ try {
295
304
  result = await execute();
305
+ // sandbox-runtime ищет shell через `which` с таймаутом в секунду и под
306
+ // нагрузкой отказывает до запуска команды. Повтор допустим, только если
307
+ // команда точно не начиналась: метки старта нет.
308
+ for (; attempts < 3 && sandboxNotStarted(result, started); attempts++) {
309
+ await new Promise((r) => setTimeout(r, 500 * attempts));
310
+ result = await execute();
311
+ }
312
+ }
313
+ finally {
314
+ // Факт старта читается до удаления метки — и при исключении после него.
315
+ if (started !== undefined && existsSync(started))
316
+ confirm();
296
317
  }
297
- return { ...result, attempts };
318
+ // Без песочницы завершившийся процесс точно порождался.
319
+ if (started === undefined)
320
+ confirm();
321
+ return { ...result, attempts, started: launched };
298
322
  }
299
323
  finally {
300
324
  // Каждый каталог убирается независимо: отказ уборки временного каталога
301
325
  // проверки (она могла снять с него права) не должен оставлять и метки.
302
326
  const removed = await Promise.allSettled([
303
- rm(scratch, { recursive: true, force: true }),
304
- rm(marks, { recursive: true, force: true }),
327
+ checkCleanup.remove(scratch),
328
+ checkCleanup.remove(marks),
305
329
  ]);
306
330
  const failed = removed.find((r) => r.status === 'rejected');
307
- if (failed)
308
- throw failed.reason;
331
+ if (failed) {
332
+ // Отказ уборки после завершения не стирает исход проверки: код и
333
+ // вывод доступны вызывающему через ошибку (`completedCheck`).
334
+ const error = failed.reason instanceof Error ? failed.reason : new Error(String(failed.reason));
335
+ throw result
336
+ ? Object.assign(error, { [checkResult]: { ...result, attempts, started: launched } })
337
+ : error;
338
+ }
309
339
  }
310
340
  }
341
+ const checkResult = Symbol('runCheck.result');
342
+ /** Исход проверки, завершившейся до исключения (например, отказа уборки), если он есть. */
343
+ export function completedCheck(error) {
344
+ return error && typeof error === 'object' && checkResult in error
345
+ ? error[checkResult]
346
+ : undefined;
347
+ }
348
+ /** Уборка каталогов проверки; тест подменяет её, чтобы проверить отказ после исхода. */
349
+ export const checkCleanup = {
350
+ remove: (dir) => rm(dir, { recursive: true, force: true }),
351
+ };
311
352
  /** Проверка механизма песочницы; тест подменяет её, чтобы проверить отказ. */
312
353
  export const isolationBackend = { check: isolationSupport };
313
354
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devcontour-harness",
3
- "version": "0.1.0-alpha.6",
3
+ "version": "0.1.0-alpha.7",
4
4
  "description": "Headless execution harness: work graph, isolated attempts, gates, independent reviews and accepted results.",
5
5
  "type": "module",
6
6
  "license": "MIT",