@tech-leads-club/harness-toolkit 0.16.5 → 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/README.md +1 -0
- package/bin/tlc-cli.ts +4 -1
- package/dist/compact-before.mjs +91 -91
- package/dist/doctor.mjs +94 -94
- package/dist/init-project.mjs +85 -85
- package/dist/install-runtime.mjs +82 -82
- package/dist/lessons-cli.mjs +91 -91
- package/dist/obs-cli.mjs +88 -88
- package/dist/prompt-submit.mjs +91 -91
- package/dist/refresh-model-prices.mjs +84 -84
- package/dist/response-after.mjs +91 -91
- package/dist/run.mjs +91 -91
- package/dist/session-end.mjs +87 -87
- package/dist/session-start.mjs +91 -91
- package/dist/shim.mjs +82 -82
- package/dist/ship-gate.mjs +96 -95
- package/dist/stop.mjs +96 -94
- package/dist/subagent-start.mjs +91 -91
- package/dist/subagent-stop.mjs +92 -92
- package/dist/support.mjs +88 -88
- package/dist/tlc-cli.mjs +108 -107
- package/dist/tool-after.mjs +87 -87
- package/dist/tool-before.mjs +106 -105
- package/dist/tool-failure.mjs +91 -91
- package/dist/uninstall-runtime.mjs +4 -4
- package/dist/verify.mjs +150 -0
- package/docs/concepts.md +25 -0
- package/docs/diagnose.md +8 -0
- package/package.json +1 -1
- package/src/core/core.facade.ts +10 -0
- package/src/core/gate/gate.verdicts.ts +107 -0
- package/src/core/index.ts +1 -0
- package/src/entrypoints/ship-gate.ts +82 -88
- package/src/entrypoints/stop.ts +155 -31
- package/src/platform/git.ts +57 -1
- package/src/platform/process.ts +19 -4
- package/tools/verify.ts +154 -0
package/docs/diagnose.md
CHANGED
|
@@ -68,6 +68,14 @@ reports can be matched to the invocation that produced it:
|
|
|
68
68
|
|
|
69
69
|
The file rotates to `hook-trace.jsonl.1` at 5 MB.
|
|
70
70
|
|
|
71
|
+
## A push is refused: no passing run is recorded for this tree
|
|
72
|
+
|
|
73
|
+
The push battery reads a verdict recorded for the exact working tree; it does not run the suite inside the hook.
|
|
74
|
+
Run `tlc harness verify` in the shell and retry. Give that command a timeout long enough for the whole suite, or run
|
|
75
|
+
it in the background and wait — Cursor's agent shell gives up on a command after about 30 seconds by default. Any
|
|
76
|
+
edit after the run changes the tree and needs a new run. A gate that rewrites files (a formatter) is not recorded:
|
|
77
|
+
review what it changed and run `verify` again.
|
|
78
|
+
|
|
71
79
|
## Node vs Bun
|
|
72
80
|
|
|
73
81
|
- Preferred: **Bun** on PATH — every hook runs the TypeScript source directly, ~1 ms per invocation.
|
package/package.json
CHANGED
package/src/core/core.facade.ts
CHANGED
|
@@ -56,6 +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 {
|
|
60
|
+
findShipVerdict,
|
|
61
|
+
lastShipVerdictFor,
|
|
62
|
+
recordShipVerdict,
|
|
63
|
+
VERIFY_SOURCE,
|
|
64
|
+
} from "./gate/gate.verdicts.ts";
|
|
59
65
|
import {
|
|
60
66
|
clearStuckSignals,
|
|
61
67
|
handoffInjectable,
|
|
@@ -317,6 +323,10 @@ export const coreFacade = {
|
|
|
317
323
|
isRecipeRunner,
|
|
318
324
|
isCommandResolutionFailure,
|
|
319
325
|
filesFromOutput,
|
|
326
|
+
findShipVerdict,
|
|
327
|
+
lastShipVerdictFor,
|
|
328
|
+
recordShipVerdict,
|
|
329
|
+
VERIFY_SOURCE,
|
|
320
330
|
},
|
|
321
331
|
stagnation: {
|
|
322
332
|
computeFingerprint,
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { updateJsonAtomic } from "../../platform/fs-atomic.ts";
|
|
4
|
+
import { linkedWorktreeMainRoot } from "../../platform/git.ts";
|
|
5
|
+
import { projectStateDir } from "../../platform/paths.ts";
|
|
6
|
+
import { trimOutputTail } from "./gate.artifact.ts";
|
|
7
|
+
|
|
8
|
+
const SHIP_VERDICT_SCHEMA = "harness.ship-verdicts.v1";
|
|
9
|
+
export const SHIP_VERDICT_LIMIT = 50;
|
|
10
|
+
export const VERIFY_SOURCE = "verify";
|
|
11
|
+
|
|
12
|
+
export type ShipVerdict = {
|
|
13
|
+
gate: string;
|
|
14
|
+
command: string[];
|
|
15
|
+
tree: string;
|
|
16
|
+
passed: boolean;
|
|
17
|
+
exitCode: number;
|
|
18
|
+
durationMs: number;
|
|
19
|
+
outputTail: string;
|
|
20
|
+
at: string;
|
|
21
|
+
timedOut?: boolean;
|
|
22
|
+
source?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type ShipVerdictFile = { schema: string; verdicts: ShipVerdict[] };
|
|
26
|
+
|
|
27
|
+
function shipVerdictsPath(root: string): string {
|
|
28
|
+
return join(projectStateDir(linkedWorktreeMainRoot(root) ?? root), "ship-verdicts.json");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function sameCommand(left: string[], right: string[]): boolean {
|
|
32
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readShipVerdicts(root: string): ShipVerdict[] {
|
|
36
|
+
const path = shipVerdictsPath(root);
|
|
37
|
+
if (!existsSync(path)) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<ShipVerdictFile>;
|
|
42
|
+
return Array.isArray(parsed.verdicts) ? parsed.verdicts : [];
|
|
43
|
+
} catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function findShipVerdict(
|
|
49
|
+
root: string,
|
|
50
|
+
gate: string,
|
|
51
|
+
command: string[],
|
|
52
|
+
tree: string,
|
|
53
|
+
sources?: readonly string[],
|
|
54
|
+
): ShipVerdict | null {
|
|
55
|
+
const matches = readShipVerdicts(root).filter(
|
|
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),
|
|
62
|
+
);
|
|
63
|
+
return matches.at(-1) ?? null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function lastShipVerdictFor(root: string, gate: string, command: string[]): ShipVerdict | null {
|
|
67
|
+
const matches = readShipVerdicts(root).filter(
|
|
68
|
+
(verdict) => verdict.gate === gate && sameCommand(verdict.command, command),
|
|
69
|
+
);
|
|
70
|
+
return matches.at(-1) ?? null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function recordShipVerdict(
|
|
74
|
+
root: string,
|
|
75
|
+
input: Omit<ShipVerdict, "at" | "outputTail" | "passed"> & { output: string; at?: string },
|
|
76
|
+
): Promise<ShipVerdict> {
|
|
77
|
+
const verdict: ShipVerdict = {
|
|
78
|
+
gate: input.gate,
|
|
79
|
+
command: input.command,
|
|
80
|
+
tree: input.tree,
|
|
81
|
+
passed: input.exitCode === 0 && input.timedOut !== true,
|
|
82
|
+
exitCode: input.exitCode,
|
|
83
|
+
durationMs: input.durationMs,
|
|
84
|
+
outputTail: trimOutputTail(input.output),
|
|
85
|
+
at: input.at ?? new Date().toISOString(),
|
|
86
|
+
...(input.timedOut === true ? { timedOut: true } : {}),
|
|
87
|
+
...(input.source !== undefined ? { source: input.source } : {}),
|
|
88
|
+
};
|
|
89
|
+
const path = shipVerdictsPath(root);
|
|
90
|
+
await updateJsonAtomic<ShipVerdictFile>(
|
|
91
|
+
path,
|
|
92
|
+
(current) => {
|
|
93
|
+
const kept = (current?.verdicts ?? []).filter(
|
|
94
|
+
(existing) =>
|
|
95
|
+
!(
|
|
96
|
+
existing.gate === verdict.gate &&
|
|
97
|
+
existing.tree === verdict.tree &&
|
|
98
|
+
existing.source === verdict.source &&
|
|
99
|
+
sameCommand(existing.command, verdict.command)
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
return { schema: SHIP_VERDICT_SCHEMA, verdicts: [...kept, verdict].slice(-SHIP_VERDICT_LIMIT) };
|
|
103
|
+
},
|
|
104
|
+
{ lockPath: `${path}.lock` },
|
|
105
|
+
);
|
|
106
|
+
return verdict;
|
|
107
|
+
}
|
package/src/core/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export type { CoreFacade } from "./core.facade.ts";
|
|
|
8
8
|
export { coreFacade } from "./core.facade.ts";
|
|
9
9
|
export type { FloorInput, FloorRule } from "./floor/floor.service.ts";
|
|
10
10
|
export type { FailureCategory, GateFinding, GateGap, LastGateArtifact, LockBody } from "./gate/gate.types.ts";
|
|
11
|
+
export type { ShipVerdict } from "./gate/gate.verdicts.ts";
|
|
11
12
|
export type {
|
|
12
13
|
ForeignSlice,
|
|
13
14
|
GateResult,
|
|
@@ -5,16 +5,15 @@ import { readFileSync } from "node:fs";
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import type { Decision, HarnessEvent } from "../contracts/index.ts";
|
|
7
7
|
import { coreFacade } from "../core/index.ts";
|
|
8
|
-
import { gitRootOf, listAddedLines, listTrackedFiles, localRepoRemote } from "../platform/git.ts";
|
|
9
|
-
import type { HandlerContext } from "./run.ts";
|
|
10
|
-
import { runLockedGate } from "./stop.ts";
|
|
11
8
|
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
} from "
|
|
9
|
+
gitRootOf,
|
|
10
|
+
listAddedLines,
|
|
11
|
+
listTrackedFiles,
|
|
12
|
+
localRepoRemote,
|
|
13
|
+
workingTreeId,
|
|
14
|
+
} from "../platform/git.ts";
|
|
15
|
+
import type { HandlerContext } from "./run.ts";
|
|
16
|
+
import { computeTurnScope, currentGitSha, pendingCommentViolations, shaScopeRoot } from "./support.ts";
|
|
18
17
|
|
|
19
18
|
// why: pr-merge is the moment code actually lands on the shared branch. Usually no local diff exists at merge
|
|
20
19
|
// time, so the battery abstains — the merged commits already paid it at push/pr-open time
|
|
@@ -62,6 +61,72 @@ function gateFailureMessage(
|
|
|
62
61
|
].join("\n");
|
|
63
62
|
}
|
|
64
63
|
|
|
64
|
+
function formatDuration(ms: number): string {
|
|
65
|
+
const seconds = Math.round(ms / 1000);
|
|
66
|
+
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
67
|
+
}
|
|
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
|
+
|
|
87
|
+
function verdictRefusal(
|
|
88
|
+
root: string,
|
|
89
|
+
tree: string,
|
|
90
|
+
required: Array<{ gate: string; command: string[] }>,
|
|
91
|
+
diag: ReturnType<typeof coreFacade.diagnostics.rootDiagnostic>,
|
|
92
|
+
): Decision | null {
|
|
93
|
+
const missing: Array<{ gate: string; command: string[] }> = [];
|
|
94
|
+
for (const { gate, command } of required) {
|
|
95
|
+
const verdict = coreFacade.gate.findShipVerdict(root, gate, command, tree);
|
|
96
|
+
if (verdict === null) {
|
|
97
|
+
missing.push({ gate, command });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (!verdict.passed) {
|
|
101
|
+
return {
|
|
102
|
+
kind: "deny",
|
|
103
|
+
reason: gateFailureMessage(gate, verdict.command, verdict.outputTail, diag),
|
|
104
|
+
rule: `ship-gate-${gate}`,
|
|
105
|
+
diagnostic: diag.summary,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (missing.length === 0) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const last = missing
|
|
113
|
+
.map(({ gate, command }) => coreFacade.gate.lastShipVerdictFor(root, gate, command))
|
|
114
|
+
.reduce((total, verdict) => total + (verdict?.durationMs ?? 0), 0);
|
|
115
|
+
const estimate = last > 0 ? ` The last run took ${formatDuration(last)}.` : "";
|
|
116
|
+
return {
|
|
117
|
+
kind: "deny",
|
|
118
|
+
reason: [
|
|
119
|
+
`BLOCKED: no passing ${missing.map(({ gate }) => gate).join(" + ")} run is recorded for this tree, and a hook cannot wait for one.`,
|
|
120
|
+
`TRIED: looked up the verdicts recorded for tree ${tree.slice(0, 12)}.`,
|
|
121
|
+
`NEED: run \`tlc harness verify\` in the shell, let it finish, then retry the commit/push/PR.${estimate} Give the command a timeout long enough for the whole suite, or run it in the background and wait for it.`,
|
|
122
|
+
"",
|
|
123
|
+
diag.footer,
|
|
124
|
+
].join("\n"),
|
|
125
|
+
rule: "ship-gate-unverified",
|
|
126
|
+
diagnostic: diag.summary,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
65
130
|
/**
|
|
66
131
|
* why after comments and before the stop-rules check: cheapest first. A comment violation costs a
|
|
67
132
|
* regex scan; a gate command is a real process; the stop-rules check is a directory read plus, only
|
|
@@ -76,7 +141,6 @@ export async function shipGateVerdict(event: HarnessEvent, ctx: HandlerContext):
|
|
|
76
141
|
const root = event.projectDir;
|
|
77
142
|
const provider = event.provider;
|
|
78
143
|
const sessionKey = event.sessionKey;
|
|
79
|
-
const session = sessionIdFromKey(event);
|
|
80
144
|
const scope = await computeTurnScope(root, shaRoot, provider, sessionKey, policy);
|
|
81
145
|
const rootDiag = coreFacade.diagnostics.rootDiagnostic(shaRoot);
|
|
82
146
|
|
|
@@ -91,84 +155,14 @@ export async function shipGateVerdict(event: HarnessEvent, ctx: HandlerContext):
|
|
|
91
155
|
};
|
|
92
156
|
}
|
|
93
157
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
policy,
|
|
103
|
-
gate: "lint",
|
|
104
|
-
command: policy.grind.lintCommand,
|
|
105
|
-
argvFiles: coreFacade.gate.shouldAppendFiles(policy.grind.lintCommand, policy.grind.appendFiles)
|
|
106
|
-
? scope.codeTargets
|
|
107
|
-
: [],
|
|
108
|
-
recordFiles: scope.codeTargets,
|
|
109
|
-
});
|
|
110
|
-
if (run.kind === "ran" && !run.artifact.passed) {
|
|
111
|
-
return {
|
|
112
|
-
kind: "deny",
|
|
113
|
-
reason: gateFailureMessage("lint", run.artifact.command, run.artifact.outputTail, rootDiag),
|
|
114
|
-
rule: "ship-gate-lint",
|
|
115
|
-
diagnostic: rootDiag.summary,
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
if (
|
|
121
|
-
policy.grind.enabled &&
|
|
122
|
-
policy.grind.testCommand &&
|
|
123
|
-
(scope.testTargets.length > 0 || scope.codeTargets.length > 0)
|
|
124
|
-
) {
|
|
125
|
-
const recordFiles = scope.testTargets.length > 0 ? scope.testTargets : scope.codeTargets;
|
|
126
|
-
const run = await runLockedGate({
|
|
127
|
-
root,
|
|
128
|
-
shaRoot,
|
|
129
|
-
provider,
|
|
130
|
-
session,
|
|
131
|
-
pendingCredit: scope.pendingCredit,
|
|
132
|
-
sessionKey,
|
|
133
|
-
policy,
|
|
134
|
-
gate: "test",
|
|
135
|
-
command: policy.grind.testCommand,
|
|
136
|
-
argvFiles: coreFacade.gate.shouldAppendFiles(policy.grind.testCommand, policy.grind.appendFiles)
|
|
137
|
-
? recordFiles
|
|
138
|
-
: [],
|
|
139
|
-
recordFiles,
|
|
140
|
-
});
|
|
141
|
-
if (run.kind === "ran" && !run.artifact.passed) {
|
|
142
|
-
return {
|
|
143
|
-
kind: "deny",
|
|
144
|
-
reason: gateFailureMessage("test", run.artifact.command, run.artifact.outputTail, rootDiag),
|
|
145
|
-
rule: "ship-gate-test",
|
|
146
|
-
diagnostic: rootDiag.summary,
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
if (policy.docs.command && policy.docs.command.length > 0 && policy.docs.severity === "deny") {
|
|
152
|
-
const run = await runLockedGate({
|
|
153
|
-
root,
|
|
154
|
-
shaRoot,
|
|
155
|
-
provider,
|
|
156
|
-
session,
|
|
157
|
-
pendingCredit: scope.pendingCredit,
|
|
158
|
-
sessionKey,
|
|
159
|
-
policy,
|
|
160
|
-
gate: "docs",
|
|
161
|
-
command: policy.docs.command,
|
|
162
|
-
argvFiles: [],
|
|
163
|
-
recordFiles: scope.changedFiles,
|
|
164
|
-
});
|
|
165
|
-
if (run.kind === "ran" && !run.artifact.passed) {
|
|
166
|
-
return {
|
|
167
|
-
kind: "deny",
|
|
168
|
-
reason: gateFailureMessage("docs", run.artifact.command, run.artifact.outputTail, rootDiag),
|
|
169
|
-
rule: "ship-gate-docs",
|
|
170
|
-
diagnostic: rootDiag.summary,
|
|
171
|
-
};
|
|
158
|
+
const required = requiredShipGates(policy, scope);
|
|
159
|
+
if (required.length > 0) {
|
|
160
|
+
const tree = await workingTreeId(shaRoot);
|
|
161
|
+
if (tree !== null) {
|
|
162
|
+
const refusal = verdictRefusal(root, tree, required, rootDiag);
|
|
163
|
+
if (refusal) {
|
|
164
|
+
return refusal;
|
|
165
|
+
}
|
|
172
166
|
}
|
|
173
167
|
}
|
|
174
168
|
|
package/src/entrypoints/stop.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
listAddedLines,
|
|
10
10
|
listChangedRepoFiles,
|
|
11
11
|
listTrackedFiles,
|
|
12
|
+
workingTreeId,
|
|
12
13
|
} from "../platform/git.ts";
|
|
13
14
|
import { flagsDir } from "../platform/paths.ts";
|
|
14
15
|
import { runCommand } from "../platform/process.ts";
|
|
@@ -134,7 +135,8 @@ function recordGateDeferred(args: {
|
|
|
134
135
|
*/
|
|
135
136
|
export type GateRun =
|
|
136
137
|
| { kind: "ran"; artifact: LastGateArtifact; reused: boolean }
|
|
137
|
-
| { kind: "deferred"; holder: string }
|
|
138
|
+
| { kind: "deferred"; holder: string }
|
|
139
|
+
| { kind: "slow"; estimateMs: number; budgetMs: number };
|
|
138
140
|
|
|
139
141
|
/**
|
|
140
142
|
* hazard: `GATE_LOCK_WAIT_MS` is 120 000 and the Stop hook is registered with `timeoutSeconds: 120`, so waiting
|
|
@@ -143,6 +145,15 @@ export type GateRun =
|
|
|
143
145
|
*/
|
|
144
146
|
export const STOP_LOCK_WAIT_MS = 10_000;
|
|
145
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
|
+
|
|
146
157
|
/**
|
|
147
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
|
|
148
159
|
* run in every environment. This is the only seam and it is read here, so the production default is a constant
|
|
@@ -191,19 +202,7 @@ async function creditPendingLessons(args: {
|
|
|
191
202
|
* ([/decisions/ad-116.md](/decisions/ad-116.md)) can ask the identical question `stop` asks — same lock, same
|
|
192
203
|
* content-hash cache, same observability. A second implementation is the drift AD-071 already named once.
|
|
193
204
|
*/
|
|
194
|
-
export async function runLockedGate(args: {
|
|
195
|
-
root: string;
|
|
196
|
-
shaRoot: string;
|
|
197
|
-
provider: string;
|
|
198
|
-
session: string;
|
|
199
|
-
gate: "lint" | "test" | "docs";
|
|
200
|
-
command: string[];
|
|
201
|
-
argvFiles: string[];
|
|
202
|
-
recordFiles: string[];
|
|
203
|
-
sessionKey: string;
|
|
204
|
-
policy: Policy;
|
|
205
|
-
pendingCredit: PendingLessonCredit | undefined;
|
|
206
|
-
}): Promise<GateRun> {
|
|
205
|
+
export async function runLockedGate(args: LockedGateArgs): Promise<GateRun> {
|
|
207
206
|
const command = [...args.command, ...args.argvFiles];
|
|
208
207
|
// why: `args.recordFiles`/`args.argvFiles` are the turn's changed files, resolved against `shaRoot` — the
|
|
209
208
|
// same split AD-129 already applied to diffing. Hashing or running against `args.root` instead reads a
|
|
@@ -217,28 +216,30 @@ export async function runLockedGate(args: {
|
|
|
217
216
|
);
|
|
218
217
|
|
|
219
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);
|
|
220
222
|
if (cached !== null) {
|
|
221
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
|
+
});
|
|
222
231
|
} else {
|
|
232
|
+
const slow = overBudget(args);
|
|
233
|
+
if (slow !== null) {
|
|
234
|
+
return slow;
|
|
235
|
+
}
|
|
236
|
+
let ran: LastGateArtifact | { timedOutAfterMs: number };
|
|
223
237
|
try {
|
|
224
|
-
|
|
238
|
+
ran = await coreFacade.gate.withGateLock(
|
|
225
239
|
args.root,
|
|
226
240
|
args.provider,
|
|
227
241
|
args.session,
|
|
228
|
-
|
|
229
|
-
const result = await runCommand(args.shaRoot, args.command, args.argvFiles);
|
|
230
|
-
return coreFacade.gate.writeLastGate({
|
|
231
|
-
root: args.root,
|
|
232
|
-
sessionKey: args.sessionKey,
|
|
233
|
-
gate: args.gate,
|
|
234
|
-
exitCode: result.exitCode,
|
|
235
|
-
command,
|
|
236
|
-
files: args.recordFiles,
|
|
237
|
-
durationMs: result.durationMs,
|
|
238
|
-
output: result.output,
|
|
239
|
-
...(inputs.complete ? { inputsHash: inputs.hash } : {}),
|
|
240
|
-
});
|
|
241
|
-
},
|
|
242
|
+
() => runAndRecord(args, command, inputs, tree),
|
|
242
243
|
{ waitMs: stopLockWaitMs() },
|
|
243
244
|
);
|
|
244
245
|
} catch (error) {
|
|
@@ -251,10 +252,14 @@ export async function runLockedGate(args: {
|
|
|
251
252
|
recordGateDeferred({ ...args, holder });
|
|
252
253
|
return { kind: "deferred", holder };
|
|
253
254
|
}
|
|
255
|
+
if ("timedOutAfterMs" in ran) {
|
|
256
|
+
return { kind: "slow", estimateMs: ran.timedOutAfterMs, budgetMs: ran.timedOutAfterMs };
|
|
257
|
+
}
|
|
258
|
+
artifact = ran;
|
|
254
259
|
}
|
|
255
260
|
|
|
256
261
|
// invariant: recorded outside the lock. A measurement must not widen the window in which one gate blocks another.
|
|
257
|
-
recordGateOutcome({ ...args, artifact, reused
|
|
262
|
+
recordGateOutcome({ ...args, artifact, reused });
|
|
258
263
|
await observeGateForRules({ ...args, gate: args.gate, passed: artifact.passed });
|
|
259
264
|
await creditPendingLessons({
|
|
260
265
|
root: args.root,
|
|
@@ -264,7 +269,97 @@ export async function runLockedGate(args: {
|
|
|
264
269
|
sessionKey: args.sessionKey,
|
|
265
270
|
passed: artifact.passed,
|
|
266
271
|
});
|
|
267
|
-
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);
|
|
268
363
|
}
|
|
269
364
|
|
|
270
365
|
async function failGate(args: {
|
|
@@ -502,6 +597,8 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
|
|
|
502
597
|
* says so once at the end instead of three times ([/decisions/ad-073.md](/decisions/ad-073.md)).
|
|
503
598
|
*/
|
|
504
599
|
const deferred: string[] = [];
|
|
600
|
+
const slow: Array<{ gate: string; estimateMs: number }> = [];
|
|
601
|
+
const gateDeadline = ctx.now.getTime() + stopGateBudgetMs();
|
|
505
602
|
const rawChangedFiles = await listChangedRepoFiles(shaRoot, turnBase);
|
|
506
603
|
const otherSessionFiles = await coreFacade.presence.filesClaimedByOtherLiveSessions(
|
|
507
604
|
root,
|
|
@@ -608,9 +705,12 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
|
|
|
608
705
|
? codeTargets
|
|
609
706
|
: [],
|
|
610
707
|
recordFiles: codeTargets,
|
|
708
|
+
deadline: gateDeadline,
|
|
611
709
|
});
|
|
612
710
|
if (run.kind === "deferred") {
|
|
613
711
|
deferred.push(run.holder);
|
|
712
|
+
} else if (run.kind === "slow") {
|
|
713
|
+
slow.push({ gate: "lint", estimateMs: run.estimateMs });
|
|
614
714
|
} else if (!run.artifact.passed) {
|
|
615
715
|
return failGate({
|
|
616
716
|
root,
|
|
@@ -646,9 +746,12 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
|
|
|
646
746
|
? testTargets
|
|
647
747
|
: [],
|
|
648
748
|
recordFiles,
|
|
749
|
+
deadline: gateDeadline,
|
|
649
750
|
});
|
|
650
751
|
if (run.kind === "deferred") {
|
|
651
752
|
deferred.push(run.holder);
|
|
753
|
+
} else if (run.kind === "slow") {
|
|
754
|
+
slow.push({ gate: "test", estimateMs: run.estimateMs });
|
|
652
755
|
} else if (!run.artifact.passed) {
|
|
653
756
|
return failGate({
|
|
654
757
|
root,
|
|
@@ -805,9 +908,12 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
|
|
|
805
908
|
command: policy.docs.command,
|
|
806
909
|
argvFiles: [],
|
|
807
910
|
recordFiles: changedFiles,
|
|
911
|
+
deadline: gateDeadline,
|
|
808
912
|
});
|
|
809
913
|
if (run.kind === "deferred") {
|
|
810
914
|
deferred.push(run.holder);
|
|
915
|
+
} else if (run.kind === "slow") {
|
|
916
|
+
slow.push({ gate: "docs", estimateMs: run.estimateMs });
|
|
811
917
|
} else if (!run.artifact.passed) {
|
|
812
918
|
if (policy.docs.severity === "deny") {
|
|
813
919
|
return failGate({
|
|
@@ -980,6 +1086,24 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
|
|
|
980
1086
|
* `{"decision":"block"}`, so telling the turn would mean blocking it — the defect being fixed. `gate.outcome` is
|
|
981
1087
|
* a `why` kind, so `tlc harness why` answers it after the fact.
|
|
982
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
|
+
}
|
|
983
1107
|
const holders = [...new Set(deferred)];
|
|
984
1108
|
await coreFacade.handoff.patchHandoff(root, provider, sessionKey, {
|
|
985
1109
|
slice: {
|