@tech-leads-club/harness-toolkit 0.16.6 → 0.16.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.
- package/dist/compact-before.mjs +76 -76
- package/dist/doctor.mjs +77 -77
- package/dist/init-project.mjs +77 -77
- package/dist/install-runtime.mjs +75 -75
- package/dist/lessons-cli.mjs +74 -74
- package/dist/obs-cli.mjs +79 -79
- package/dist/prompt-submit.mjs +76 -76
- package/dist/refresh-model-prices.mjs +75 -75
- package/dist/response-after.mjs +76 -76
- package/dist/run.mjs +76 -76
- package/dist/session-end.mjs +76 -76
- package/dist/session-start.mjs +80 -80
- package/dist/shim.mjs +74 -74
- package/dist/ship-gate.mjs +77 -77
- package/dist/stop.mjs +96 -95
- package/dist/subagent-start.mjs +76 -76
- package/dist/subagent-stop.mjs +77 -77
- package/dist/support.mjs +75 -75
- package/dist/tlc-cli.mjs +91 -91
- package/dist/tool-after.mjs +76 -76
- package/dist/tool-before.mjs +85 -85
- package/dist/tool-failure.mjs +76 -76
- package/dist/uninstall-runtime.mjs +2 -2
- package/dist/verify.mjs +83 -83
- package/docs/concepts.md +5 -0
- package/package.json +1 -1
- package/src/core/core.facade.ts +7 -1
- package/src/core/gate/gate.verdicts.ts +14 -2
- package/src/entrypoints/ship-gate.ts +19 -14
- package/src/entrypoints/stop.ts +154 -42
- package/src/platform/git.ts +1 -3
- package/src/platform/process.ts +19 -4
- package/tools/verify.ts +13 -4
package/docs/concepts.md
CHANGED
|
@@ -150,6 +150,11 @@ step. The tree id covers uncommitted and untracked files, so a verdict recorded
|
|
|
150
150
|
push of that commit, and any edit after it does not. Linked worktrees share one record with their main checkout. A
|
|
151
151
|
gate that rewrites files while it runs is not recorded, because its verdict describes a tree that no longer exists.
|
|
152
152
|
|
|
153
|
+
The `stop` grind follows the same rule. It reuses a verdict recorded for the current tree by this session or by
|
|
154
|
+
`tlc harness verify`, and otherwise runs the gate inline within a 90-second budget, ending the whole process tree if
|
|
155
|
+
it overruns. A suite that does not fit is handed back: the turn continues with an instruction to run `tlc harness
|
|
156
|
+
verify`, and the overrun is remembered as an estimate — never as a verdict — so the next stop does not start it again.
|
|
157
|
+
|
|
153
158
|
## pause / resume
|
|
154
159
|
|
|
155
160
|
`tlc harness pause` is the circuit breaker: every hook steps aside and answers at once, reading no policy and
|
package/package.json
CHANGED
package/src/core/core.facade.ts
CHANGED
|
@@ -56,7 +56,12 @@ import { filesFromOutput } from "./gate/gate.findings.ts";
|
|
|
56
56
|
import { cachedVerdict, computeInputsHash, isCacheHit } from "./gate/gate.inputs.ts";
|
|
57
57
|
import { describeHolder, GATE_LOCK_WAIT_MS, GateLockTimeoutError, withGateLock } from "./gate/gate.lock.ts";
|
|
58
58
|
import { gapsFromArtifact } from "./gate/gate.service.ts";
|
|
59
|
-
import {
|
|
59
|
+
import {
|
|
60
|
+
findShipVerdict,
|
|
61
|
+
lastShipVerdictFor,
|
|
62
|
+
recordShipVerdict,
|
|
63
|
+
VERIFY_SOURCE,
|
|
64
|
+
} from "./gate/gate.verdicts.ts";
|
|
60
65
|
import {
|
|
61
66
|
clearStuckSignals,
|
|
62
67
|
handoffInjectable,
|
|
@@ -321,6 +326,7 @@ export const coreFacade = {
|
|
|
321
326
|
findShipVerdict,
|
|
322
327
|
lastShipVerdictFor,
|
|
323
328
|
recordShipVerdict,
|
|
329
|
+
VERIFY_SOURCE,
|
|
324
330
|
},
|
|
325
331
|
stagnation: {
|
|
326
332
|
computeFingerprint,
|
|
@@ -7,6 +7,7 @@ import { trimOutputTail } from "./gate.artifact.ts";
|
|
|
7
7
|
|
|
8
8
|
const SHIP_VERDICT_SCHEMA = "harness.ship-verdicts.v1";
|
|
9
9
|
export const SHIP_VERDICT_LIMIT = 50;
|
|
10
|
+
export const VERIFY_SOURCE = "verify";
|
|
10
11
|
|
|
11
12
|
export type ShipVerdict = {
|
|
12
13
|
gate: string;
|
|
@@ -17,6 +18,8 @@ export type ShipVerdict = {
|
|
|
17
18
|
durationMs: number;
|
|
18
19
|
outputTail: string;
|
|
19
20
|
at: string;
|
|
21
|
+
timedOut?: boolean;
|
|
22
|
+
source?: string;
|
|
20
23
|
};
|
|
21
24
|
|
|
22
25
|
type ShipVerdictFile = { schema: string; verdicts: ShipVerdict[] };
|
|
@@ -47,9 +50,15 @@ export function findShipVerdict(
|
|
|
47
50
|
gate: string,
|
|
48
51
|
command: string[],
|
|
49
52
|
tree: string,
|
|
53
|
+
sources?: readonly string[],
|
|
50
54
|
): ShipVerdict | null {
|
|
51
55
|
const matches = readShipVerdicts(root).filter(
|
|
52
|
-
(verdict) =>
|
|
56
|
+
(verdict) =>
|
|
57
|
+
verdict.timedOut !== true &&
|
|
58
|
+
(sources === undefined || (verdict.source !== undefined && sources.includes(verdict.source))) &&
|
|
59
|
+
verdict.gate === gate &&
|
|
60
|
+
verdict.tree === tree &&
|
|
61
|
+
sameCommand(verdict.command, command),
|
|
53
62
|
);
|
|
54
63
|
return matches.at(-1) ?? null;
|
|
55
64
|
}
|
|
@@ -69,11 +78,13 @@ export async function recordShipVerdict(
|
|
|
69
78
|
gate: input.gate,
|
|
70
79
|
command: input.command,
|
|
71
80
|
tree: input.tree,
|
|
72
|
-
passed: input.exitCode === 0,
|
|
81
|
+
passed: input.exitCode === 0 && input.timedOut !== true,
|
|
73
82
|
exitCode: input.exitCode,
|
|
74
83
|
durationMs: input.durationMs,
|
|
75
84
|
outputTail: trimOutputTail(input.output),
|
|
76
85
|
at: input.at ?? new Date().toISOString(),
|
|
86
|
+
...(input.timedOut === true ? { timedOut: true } : {}),
|
|
87
|
+
...(input.source !== undefined ? { source: input.source } : {}),
|
|
77
88
|
};
|
|
78
89
|
const path = shipVerdictsPath(root);
|
|
79
90
|
await updateJsonAtomic<ShipVerdictFile>(
|
|
@@ -84,6 +95,7 @@ export async function recordShipVerdict(
|
|
|
84
95
|
!(
|
|
85
96
|
existing.gate === verdict.gate &&
|
|
86
97
|
existing.tree === verdict.tree &&
|
|
98
|
+
existing.source === verdict.source &&
|
|
87
99
|
sameCommand(existing.command, verdict.command)
|
|
88
100
|
),
|
|
89
101
|
);
|
|
@@ -66,6 +66,24 @@ function formatDuration(ms: number): string {
|
|
|
66
66
|
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
function requiredShipGates(
|
|
70
|
+
policy: HandlerContext["policy"],
|
|
71
|
+
scope: { codeTargets: string[]; testTargets: string[] },
|
|
72
|
+
): Array<{ gate: string; command: string[] }> {
|
|
73
|
+
const required: Array<{ gate: string; command: string[] }> = [];
|
|
74
|
+
const changedCode = scope.codeTargets.length > 0;
|
|
75
|
+
if (policy.grind.enabled && policy.grind.lintCommand && changedCode) {
|
|
76
|
+
required.push({ gate: "lint", command: policy.grind.lintCommand });
|
|
77
|
+
}
|
|
78
|
+
if (policy.grind.enabled && policy.grind.testCommand && (changedCode || scope.testTargets.length > 0)) {
|
|
79
|
+
required.push({ gate: "test", command: policy.grind.testCommand });
|
|
80
|
+
}
|
|
81
|
+
if (policy.docs.command && policy.docs.command.length > 0 && policy.docs.severity === "deny") {
|
|
82
|
+
required.push({ gate: "docs", command: policy.docs.command });
|
|
83
|
+
}
|
|
84
|
+
return required;
|
|
85
|
+
}
|
|
86
|
+
|
|
69
87
|
function verdictRefusal(
|
|
70
88
|
root: string,
|
|
71
89
|
tree: string,
|
|
@@ -137,20 +155,7 @@ export async function shipGateVerdict(event: HarnessEvent, ctx: HandlerContext):
|
|
|
137
155
|
};
|
|
138
156
|
}
|
|
139
157
|
|
|
140
|
-
const required
|
|
141
|
-
if (policy.grind.enabled && policy.grind.lintCommand && scope.codeTargets.length > 0) {
|
|
142
|
-
required.push({ gate: "lint", command: policy.grind.lintCommand });
|
|
143
|
-
}
|
|
144
|
-
if (
|
|
145
|
-
policy.grind.enabled &&
|
|
146
|
-
policy.grind.testCommand &&
|
|
147
|
-
(scope.testTargets.length > 0 || scope.codeTargets.length > 0)
|
|
148
|
-
) {
|
|
149
|
-
required.push({ gate: "test", command: policy.grind.testCommand });
|
|
150
|
-
}
|
|
151
|
-
if (policy.docs.command && policy.docs.command.length > 0 && policy.docs.severity === "deny") {
|
|
152
|
-
required.push({ gate: "docs", command: policy.docs.command });
|
|
153
|
-
}
|
|
158
|
+
const required = requiredShipGates(policy, scope);
|
|
154
159
|
if (required.length > 0) {
|
|
155
160
|
const tree = await workingTreeId(shaRoot);
|
|
156
161
|
if (tree !== null) {
|
package/src/entrypoints/stop.ts
CHANGED
|
@@ -135,7 +135,8 @@ function recordGateDeferred(args: {
|
|
|
135
135
|
*/
|
|
136
136
|
export type GateRun =
|
|
137
137
|
| { kind: "ran"; artifact: LastGateArtifact; reused: boolean }
|
|
138
|
-
| { kind: "deferred"; holder: string }
|
|
138
|
+
| { kind: "deferred"; holder: string }
|
|
139
|
+
| { kind: "slow"; estimateMs: number; budgetMs: number };
|
|
139
140
|
|
|
140
141
|
/**
|
|
141
142
|
* hazard: `GATE_LOCK_WAIT_MS` is 120 000 and the Stop hook is registered with `timeoutSeconds: 120`, so waiting
|
|
@@ -144,6 +145,15 @@ export type GateRun =
|
|
|
144
145
|
*/
|
|
145
146
|
export const STOP_LOCK_WAIT_MS = 10_000;
|
|
146
147
|
|
|
148
|
+
export const STOP_GATE_BUDGET_MS = 90_000;
|
|
149
|
+
|
|
150
|
+
const TIMED_OUT_HEADROOM = 1.25;
|
|
151
|
+
|
|
152
|
+
export function stopGateBudgetMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
153
|
+
const declared = Number(env.TLC_TEST_STOP_GATE_BUDGET_MS);
|
|
154
|
+
return Number.isFinite(declared) && declared > 0 ? declared : STOP_GATE_BUDGET_MS;
|
|
155
|
+
}
|
|
156
|
+
|
|
147
157
|
/**
|
|
148
158
|
* why: a test that proves the wait has to wait, and a suite that pays ten seconds for it pays that on every gate
|
|
149
159
|
* run in every environment. This is the only seam and it is read here, so the production default is a constant
|
|
@@ -192,19 +202,7 @@ async function creditPendingLessons(args: {
|
|
|
192
202
|
* ([/decisions/ad-116.md](/decisions/ad-116.md)) can ask the identical question `stop` asks — same lock, same
|
|
193
203
|
* content-hash cache, same observability. A second implementation is the drift AD-071 already named once.
|
|
194
204
|
*/
|
|
195
|
-
export async function runLockedGate(args: {
|
|
196
|
-
root: string;
|
|
197
|
-
shaRoot: string;
|
|
198
|
-
provider: string;
|
|
199
|
-
session: string;
|
|
200
|
-
gate: "lint" | "test" | "docs";
|
|
201
|
-
command: string[];
|
|
202
|
-
argvFiles: string[];
|
|
203
|
-
recordFiles: string[];
|
|
204
|
-
sessionKey: string;
|
|
205
|
-
policy: Policy;
|
|
206
|
-
pendingCredit: PendingLessonCredit | undefined;
|
|
207
|
-
}): Promise<GateRun> {
|
|
205
|
+
export async function runLockedGate(args: LockedGateArgs): Promise<GateRun> {
|
|
208
206
|
const command = [...args.command, ...args.argvFiles];
|
|
209
207
|
// why: `args.recordFiles`/`args.argvFiles` are the turn's changed files, resolved against `shaRoot` — the
|
|
210
208
|
// same split AD-129 already applied to diffing. Hashing or running against `args.root` instead reads a
|
|
@@ -218,39 +216,30 @@ export async function runLockedGate(args: {
|
|
|
218
216
|
);
|
|
219
217
|
|
|
220
218
|
let artifact: LastGateArtifact;
|
|
219
|
+
let reused = cached !== null;
|
|
220
|
+
const tree = cached === null ? await workingTreeId(args.shaRoot) : null;
|
|
221
|
+
const recorded = recordedVerdictFor(args, tree);
|
|
221
222
|
if (cached !== null) {
|
|
222
223
|
artifact = cached;
|
|
224
|
+
} else if (recorded !== null) {
|
|
225
|
+
reused = true;
|
|
226
|
+
artifact = writeArtifact(args, command, inputs, {
|
|
227
|
+
exitCode: recorded.exitCode,
|
|
228
|
+
durationMs: recorded.durationMs,
|
|
229
|
+
output: recorded.outputTail,
|
|
230
|
+
});
|
|
223
231
|
} else {
|
|
232
|
+
const slow = overBudget(args);
|
|
233
|
+
if (slow !== null) {
|
|
234
|
+
return slow;
|
|
235
|
+
}
|
|
236
|
+
let ran: LastGateArtifact | { timedOutAfterMs: number };
|
|
224
237
|
try {
|
|
225
|
-
|
|
238
|
+
ran = await coreFacade.gate.withGateLock(
|
|
226
239
|
args.root,
|
|
227
240
|
args.provider,
|
|
228
241
|
args.session,
|
|
229
|
-
|
|
230
|
-
const treeBefore = await workingTreeId(args.shaRoot);
|
|
231
|
-
const result = await runCommand(args.shaRoot, args.command, args.argvFiles);
|
|
232
|
-
if (treeBefore !== null && (await workingTreeId(args.shaRoot)) === treeBefore) {
|
|
233
|
-
await coreFacade.gate.recordShipVerdict(args.root, {
|
|
234
|
-
gate: args.gate,
|
|
235
|
-
command: args.command,
|
|
236
|
-
tree: treeBefore,
|
|
237
|
-
exitCode: result.exitCode,
|
|
238
|
-
durationMs: result.durationMs,
|
|
239
|
-
output: result.output,
|
|
240
|
-
});
|
|
241
|
-
}
|
|
242
|
-
return coreFacade.gate.writeLastGate({
|
|
243
|
-
root: args.root,
|
|
244
|
-
sessionKey: args.sessionKey,
|
|
245
|
-
gate: args.gate,
|
|
246
|
-
exitCode: result.exitCode,
|
|
247
|
-
command,
|
|
248
|
-
files: args.recordFiles,
|
|
249
|
-
durationMs: result.durationMs,
|
|
250
|
-
output: result.output,
|
|
251
|
-
...(inputs.complete ? { inputsHash: inputs.hash } : {}),
|
|
252
|
-
});
|
|
253
|
-
},
|
|
242
|
+
() => runAndRecord(args, command, inputs, tree),
|
|
254
243
|
{ waitMs: stopLockWaitMs() },
|
|
255
244
|
);
|
|
256
245
|
} catch (error) {
|
|
@@ -263,10 +252,14 @@ export async function runLockedGate(args: {
|
|
|
263
252
|
recordGateDeferred({ ...args, holder });
|
|
264
253
|
return { kind: "deferred", holder };
|
|
265
254
|
}
|
|
255
|
+
if ("timedOutAfterMs" in ran) {
|
|
256
|
+
return { kind: "slow", estimateMs: ran.timedOutAfterMs, budgetMs: ran.timedOutAfterMs };
|
|
257
|
+
}
|
|
258
|
+
artifact = ran;
|
|
266
259
|
}
|
|
267
260
|
|
|
268
261
|
// invariant: recorded outside the lock. A measurement must not widen the window in which one gate blocks another.
|
|
269
|
-
recordGateOutcome({ ...args, artifact, reused
|
|
262
|
+
recordGateOutcome({ ...args, artifact, reused });
|
|
270
263
|
await observeGateForRules({ ...args, gate: args.gate, passed: artifact.passed });
|
|
271
264
|
await creditPendingLessons({
|
|
272
265
|
root: args.root,
|
|
@@ -276,7 +269,97 @@ export async function runLockedGate(args: {
|
|
|
276
269
|
sessionKey: args.sessionKey,
|
|
277
270
|
passed: artifact.passed,
|
|
278
271
|
});
|
|
279
|
-
return { kind: "ran", artifact, reused
|
|
272
|
+
return { kind: "ran", artifact, reused };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
type LockedGateArgs = {
|
|
276
|
+
root: string;
|
|
277
|
+
shaRoot: string;
|
|
278
|
+
provider: string;
|
|
279
|
+
session: string;
|
|
280
|
+
gate: "lint" | "test" | "docs";
|
|
281
|
+
command: string[];
|
|
282
|
+
argvFiles: string[];
|
|
283
|
+
recordFiles: string[];
|
|
284
|
+
sessionKey: string;
|
|
285
|
+
policy: Policy;
|
|
286
|
+
pendingCredit: PendingLessonCredit | undefined;
|
|
287
|
+
deadline?: number;
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
type GateInputs = ReturnType<typeof coreFacade.gate.computeInputsHash>;
|
|
291
|
+
|
|
292
|
+
function recordedVerdictFor(args: LockedGateArgs, tree: string | null) {
|
|
293
|
+
if (tree === null) {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
return coreFacade.gate.findShipVerdict(args.root, args.gate, args.command, tree, [
|
|
297
|
+
args.sessionKey,
|
|
298
|
+
coreFacade.gate.VERIFY_SOURCE,
|
|
299
|
+
]);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function writeArtifact(
|
|
303
|
+
args: LockedGateArgs,
|
|
304
|
+
command: string[],
|
|
305
|
+
inputs: GateInputs,
|
|
306
|
+
result: { exitCode: number; durationMs: number; output: string },
|
|
307
|
+
): LastGateArtifact {
|
|
308
|
+
return coreFacade.gate.writeLastGate({
|
|
309
|
+
root: args.root,
|
|
310
|
+
sessionKey: args.sessionKey,
|
|
311
|
+
gate: args.gate,
|
|
312
|
+
exitCode: result.exitCode,
|
|
313
|
+
command,
|
|
314
|
+
files: args.recordFiles,
|
|
315
|
+
durationMs: result.durationMs,
|
|
316
|
+
output: result.output,
|
|
317
|
+
...(inputs.complete ? { inputsHash: inputs.hash } : {}),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function overBudget(args: LockedGateArgs): GateRun | null {
|
|
322
|
+
if (args.deadline === undefined) {
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
const last = coreFacade.gate.lastShipVerdictFor(args.root, args.gate, args.command);
|
|
326
|
+
const needed = last?.timedOut === true ? last.durationMs * TIMED_OUT_HEADROOM : last?.durationMs;
|
|
327
|
+
const budgetMs = args.deadline - Date.now();
|
|
328
|
+
if (budgetMs > 0 && (needed === undefined || needed < budgetMs)) {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
return { kind: "slow", estimateMs: last?.durationMs ?? 0, budgetMs: Math.max(0, budgetMs) };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function runAndRecord(
|
|
335
|
+
args: LockedGateArgs,
|
|
336
|
+
command: string[],
|
|
337
|
+
inputs: GateInputs,
|
|
338
|
+
tree: string | null,
|
|
339
|
+
): Promise<LastGateArtifact | { timedOutAfterMs: number }> {
|
|
340
|
+
const timeoutMs = args.deadline !== undefined ? Math.max(1, args.deadline - Date.now()) : undefined;
|
|
341
|
+
const result = await runCommand(
|
|
342
|
+
args.shaRoot,
|
|
343
|
+
args.command,
|
|
344
|
+
args.argvFiles,
|
|
345
|
+
timeoutMs !== undefined ? { timeoutMs } : {},
|
|
346
|
+
);
|
|
347
|
+
if (tree !== null && (await workingTreeId(args.shaRoot)) === tree) {
|
|
348
|
+
await coreFacade.gate.recordShipVerdict(args.root, {
|
|
349
|
+
gate: args.gate,
|
|
350
|
+
command: args.command,
|
|
351
|
+
tree,
|
|
352
|
+
exitCode: result.exitCode,
|
|
353
|
+
durationMs: result.durationMs,
|
|
354
|
+
output: result.output,
|
|
355
|
+
source: args.sessionKey,
|
|
356
|
+
...(result.timedOut ? { timedOut: true } : {}),
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
if (result.timedOut) {
|
|
360
|
+
return { timedOutAfterMs: result.durationMs };
|
|
361
|
+
}
|
|
362
|
+
return writeArtifact(args, command, inputs, result);
|
|
280
363
|
}
|
|
281
364
|
|
|
282
365
|
async function failGate(args: {
|
|
@@ -514,6 +597,8 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
|
|
|
514
597
|
* says so once at the end instead of three times ([/decisions/ad-073.md](/decisions/ad-073.md)).
|
|
515
598
|
*/
|
|
516
599
|
const deferred: string[] = [];
|
|
600
|
+
const slow: Array<{ gate: string; estimateMs: number }> = [];
|
|
601
|
+
const gateDeadline = ctx.now.getTime() + stopGateBudgetMs();
|
|
517
602
|
const rawChangedFiles = await listChangedRepoFiles(shaRoot, turnBase);
|
|
518
603
|
const otherSessionFiles = await coreFacade.presence.filesClaimedByOtherLiveSessions(
|
|
519
604
|
root,
|
|
@@ -620,9 +705,12 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
|
|
|
620
705
|
? codeTargets
|
|
621
706
|
: [],
|
|
622
707
|
recordFiles: codeTargets,
|
|
708
|
+
deadline: gateDeadline,
|
|
623
709
|
});
|
|
624
710
|
if (run.kind === "deferred") {
|
|
625
711
|
deferred.push(run.holder);
|
|
712
|
+
} else if (run.kind === "slow") {
|
|
713
|
+
slow.push({ gate: "lint", estimateMs: run.estimateMs });
|
|
626
714
|
} else if (!run.artifact.passed) {
|
|
627
715
|
return failGate({
|
|
628
716
|
root,
|
|
@@ -658,9 +746,12 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
|
|
|
658
746
|
? testTargets
|
|
659
747
|
: [],
|
|
660
748
|
recordFiles,
|
|
749
|
+
deadline: gateDeadline,
|
|
661
750
|
});
|
|
662
751
|
if (run.kind === "deferred") {
|
|
663
752
|
deferred.push(run.holder);
|
|
753
|
+
} else if (run.kind === "slow") {
|
|
754
|
+
slow.push({ gate: "test", estimateMs: run.estimateMs });
|
|
664
755
|
} else if (!run.artifact.passed) {
|
|
665
756
|
return failGate({
|
|
666
757
|
root,
|
|
@@ -817,9 +908,12 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
|
|
|
817
908
|
command: policy.docs.command,
|
|
818
909
|
argvFiles: [],
|
|
819
910
|
recordFiles: changedFiles,
|
|
911
|
+
deadline: gateDeadline,
|
|
820
912
|
});
|
|
821
913
|
if (run.kind === "deferred") {
|
|
822
914
|
deferred.push(run.holder);
|
|
915
|
+
} else if (run.kind === "slow") {
|
|
916
|
+
slow.push({ gate: "docs", estimateMs: run.estimateMs });
|
|
823
917
|
} else if (!run.artifact.passed) {
|
|
824
918
|
if (policy.docs.severity === "deny") {
|
|
825
919
|
return failGate({
|
|
@@ -992,6 +1086,24 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
|
|
|
992
1086
|
* `{"decision":"block"}`, so telling the turn would mean blocking it — the defect being fixed. `gate.outcome` is
|
|
993
1087
|
* a `why` kind, so `tlc harness why` answers it after the fact.
|
|
994
1088
|
*/
|
|
1089
|
+
if (slow.length > 0) {
|
|
1090
|
+
const names = slow.map(({ gate }) => gate).join(" + ");
|
|
1091
|
+
const longest = Math.max(...slow.map(({ estimateMs }) => estimateMs));
|
|
1092
|
+
await coreFacade.handoff.patchHandoff(root, provider, sessionKey, {
|
|
1093
|
+
slice: {
|
|
1094
|
+
last_gate_result: "skipped",
|
|
1095
|
+
next_action: "Run `tlc harness verify` and let it finish before ending the turn.",
|
|
1096
|
+
},
|
|
1097
|
+
});
|
|
1098
|
+
return {
|
|
1099
|
+
kind: "continue",
|
|
1100
|
+
text: [
|
|
1101
|
+
`BLOCKED: the ${names} gate needs longer than a stop hook may run, so this turn's changes are not verified yet.`,
|
|
1102
|
+
`TRIED: ${longest > 0 ? `the last run took ${Math.round(longest / 1000)}s` : "it did not finish"}; the stop hook gives its gates ${Math.round(stopGateBudgetMs() / 1000)}s.`,
|
|
1103
|
+
"NEED: run `tlc harness verify` in the shell and let it finish — give it a timeout long enough for the whole suite, or run it in the background and wait — then end the turn. Its verdict is reused here and at push.",
|
|
1104
|
+
].join("\n"),
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
995
1107
|
const holders = [...new Set(deferred)];
|
|
996
1108
|
await coreFacade.handoff.patchHandoff(root, provider, sessionKey, {
|
|
997
1109
|
slice: {
|
package/src/platform/git.ts
CHANGED
|
@@ -103,9 +103,7 @@ export async function workingTreeId(dir: string): Promise<string | null> {
|
|
|
103
103
|
} else if ((await git(["read-tree", "--empty"])).exitCode !== 0) {
|
|
104
104
|
return null;
|
|
105
105
|
}
|
|
106
|
-
|
|
107
|
-
return null;
|
|
108
|
-
}
|
|
106
|
+
await git(["add", "-A", "--ignore-errors"]);
|
|
109
107
|
const tree = await git(["write-tree"]);
|
|
110
108
|
const id = tree.stdout.trim();
|
|
111
109
|
return tree.exitCode === 0 && id.length > 0 ? id : null;
|
package/src/platform/process.ts
CHANGED
|
@@ -52,16 +52,18 @@ export async function runProcess(args: {
|
|
|
52
52
|
input?: string;
|
|
53
53
|
env?: NodeJS.ProcessEnv;
|
|
54
54
|
timeoutMs?: number;
|
|
55
|
-
}): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
|
55
|
+
}): Promise<{ exitCode: number; stdout: string; stderr: string; timedOut?: boolean }> {
|
|
56
56
|
const [file, ...argv] = args.command;
|
|
57
57
|
if (file === undefined) {
|
|
58
58
|
return { exitCode: 0, stdout: "", stderr: "" };
|
|
59
59
|
}
|
|
60
|
+
const ownGroup = args.timeoutMs !== undefined && process.platform !== "win32";
|
|
60
61
|
return await new Promise((resolve, reject) => {
|
|
61
62
|
const child = spawn(file, argv, {
|
|
62
63
|
cwd: args.cwd,
|
|
63
64
|
stdio: ["pipe", "pipe", "pipe"] as const,
|
|
64
65
|
env: args.env ?? process.env,
|
|
66
|
+
detached: ownGroup,
|
|
65
67
|
});
|
|
66
68
|
let stdout = "";
|
|
67
69
|
let stderr = "";
|
|
@@ -71,6 +73,12 @@ export async function runProcess(args: {
|
|
|
71
73
|
? undefined
|
|
72
74
|
: setTimeout(() => {
|
|
73
75
|
timedOut = true;
|
|
76
|
+
try {
|
|
77
|
+
if (ownGroup && child.pid !== undefined) {
|
|
78
|
+
process.kill(-child.pid, "SIGKILL");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
} catch {}
|
|
74
82
|
child.kill("SIGKILL");
|
|
75
83
|
}, args.timeoutMs);
|
|
76
84
|
|
|
@@ -87,7 +95,12 @@ export async function runProcess(args: {
|
|
|
87
95
|
child.on("close", (code) => {
|
|
88
96
|
if (timer) clearTimeout(timer);
|
|
89
97
|
if (timedOut) {
|
|
90
|
-
resolve({
|
|
98
|
+
resolve({
|
|
99
|
+
exitCode: TIMEOUT_EXIT_CODE,
|
|
100
|
+
stdout,
|
|
101
|
+
stderr: `${stderr}\n(process timed out)`,
|
|
102
|
+
timedOut: true,
|
|
103
|
+
});
|
|
91
104
|
return;
|
|
92
105
|
}
|
|
93
106
|
resolve({ exitCode: code ?? 1, stdout, stderr });
|
|
@@ -99,7 +112,7 @@ export async function runProcess(args: {
|
|
|
99
112
|
});
|
|
100
113
|
}
|
|
101
114
|
|
|
102
|
-
export type CommandResult = { exitCode: number; output: string; durationMs: number };
|
|
115
|
+
export type CommandResult = { exitCode: number; output: string; durationMs: number; timedOut?: boolean };
|
|
103
116
|
|
|
104
117
|
export const NO_OUTPUT_CAPTURED = "(no output captured)";
|
|
105
118
|
|
|
@@ -114,7 +127,7 @@ export async function runCommand(
|
|
|
114
127
|
projectDir: string,
|
|
115
128
|
command: string[],
|
|
116
129
|
extraArgs: string[] = [],
|
|
117
|
-
options: { env?: NodeJS.ProcessEnv } = {},
|
|
130
|
+
options: { env?: NodeJS.ProcessEnv; timeoutMs?: number } = {},
|
|
118
131
|
): Promise<CommandResult> {
|
|
119
132
|
if (command.length === 0) {
|
|
120
133
|
return { exitCode: 0, output: "", durationMs: 0 };
|
|
@@ -124,6 +137,7 @@ export async function runCommand(
|
|
|
124
137
|
command: [...command, ...extraArgs],
|
|
125
138
|
cwd: projectDir,
|
|
126
139
|
env: options.env ? { ...process.env, ...options.env } : process.env,
|
|
140
|
+
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
|
|
127
141
|
});
|
|
128
142
|
const combined = (result.stdout + result.stderr).trim();
|
|
129
143
|
const output = combined.length === 0 ? NO_OUTPUT_CAPTURED : combined;
|
|
@@ -131,5 +145,6 @@ export async function runCommand(
|
|
|
131
145
|
exitCode: result.exitCode,
|
|
132
146
|
output,
|
|
133
147
|
durationMs: Date.now() - started,
|
|
148
|
+
...(result.timedOut ? { timedOut: true } : {}),
|
|
134
149
|
};
|
|
135
150
|
}
|
package/tools/verify.ts
CHANGED
|
@@ -15,6 +15,7 @@ export type VerifyOutcome = {
|
|
|
15
15
|
durationMs: number;
|
|
16
16
|
recorded: boolean;
|
|
17
17
|
treeChanged: boolean;
|
|
18
|
+
unidentified: boolean;
|
|
18
19
|
};
|
|
19
20
|
|
|
20
21
|
export function gatesToVerify(policy: ReturnType<typeof coreFacade.policy.loadPolicy>): VerifyGate[] {
|
|
@@ -74,8 +75,9 @@ export async function verify(args: {
|
|
|
74
75
|
log(`▸ ${gate}: ${command.join(" ")}`);
|
|
75
76
|
const result = await runStreaming(command, gitRoot, args.echo ?? true);
|
|
76
77
|
tree = await workingTreeId(gitRoot);
|
|
77
|
-
const
|
|
78
|
-
|
|
78
|
+
const unidentified = before === null || tree === null;
|
|
79
|
+
const treeChanged = !unidentified && tree !== before;
|
|
80
|
+
if (!unidentified && !treeChanged && before !== null) {
|
|
79
81
|
await coreFacade.gate.recordShipVerdict(args.root, {
|
|
80
82
|
gate,
|
|
81
83
|
command,
|
|
@@ -83,6 +85,7 @@ export async function verify(args: {
|
|
|
83
85
|
exitCode: result.exitCode,
|
|
84
86
|
durationMs: result.durationMs,
|
|
85
87
|
output: result.output,
|
|
88
|
+
source: coreFacade.gate.VERIFY_SOURCE,
|
|
86
89
|
});
|
|
87
90
|
}
|
|
88
91
|
outcomes.push({
|
|
@@ -90,8 +93,9 @@ export async function verify(args: {
|
|
|
90
93
|
command,
|
|
91
94
|
exitCode: result.exitCode,
|
|
92
95
|
durationMs: result.durationMs,
|
|
93
|
-
recorded: !treeChanged,
|
|
96
|
+
recorded: !unidentified && !treeChanged,
|
|
94
97
|
treeChanged,
|
|
98
|
+
unidentified,
|
|
95
99
|
});
|
|
96
100
|
log(
|
|
97
101
|
`${result.exitCode === 0 ? "✔" : "✖"} ${gate} — exit ${result.exitCode} in ${Math.round(result.durationMs / 1000)}s`,
|
|
@@ -126,10 +130,15 @@ export async function main(argv: string[]): Promise<void> {
|
|
|
126
130
|
});
|
|
127
131
|
const failed = outcomes.find((outcome) => outcome.exitCode !== 0);
|
|
128
132
|
const changed = outcomes.find((outcome) => outcome.treeChanged);
|
|
133
|
+
const unidentified = outcomes.find((outcome) => outcome.unidentified);
|
|
129
134
|
if (json) {
|
|
130
135
|
emitJson({ tree, outcomes });
|
|
131
136
|
} else if (failed) {
|
|
132
137
|
console.log(`verify: ${failed.gate} failed — fix it and run \`tlc harness verify\` again`);
|
|
138
|
+
} else if (unidentified) {
|
|
139
|
+
console.log(
|
|
140
|
+
"verify: git could not identify the working tree, so no verdict was recorded — run `git status` to see what it cannot read",
|
|
141
|
+
);
|
|
133
142
|
} else if (changed) {
|
|
134
143
|
console.log(
|
|
135
144
|
`verify: ${changed.gate} changed files while it ran, so its result describes a tree that no longer exists — review the changes and run \`tlc harness verify\` again`,
|
|
@@ -137,7 +146,7 @@ export async function main(argv: string[]): Promise<void> {
|
|
|
137
146
|
} else {
|
|
138
147
|
console.log(`verify: all gates passed for tree ${tree?.slice(0, 12)} — commit, push and PR may proceed`);
|
|
139
148
|
}
|
|
140
|
-
process.exit(failed || changed ? 1 : 0);
|
|
149
|
+
process.exit(failed || changed || unidentified ? 1 : 0);
|
|
141
150
|
}
|
|
142
151
|
|
|
143
152
|
if (import.meta.main) {
|