tickmarkr 2.1.8 → 2.2.0
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/cli/commands/approve.d.ts +24 -0
- package/dist/cli/commands/approve.js +121 -34
- package/dist/compile/collateral.js +149 -4
- package/dist/compile/native.js +16 -0
- package/dist/drivers/herdr.d.ts +7 -5
- package/dist/drivers/herdr.js +142 -11
- package/dist/drivers/types.d.ts +5 -6
- package/dist/drivers/types.js +2 -48
- package/dist/run/daemon.d.ts +10 -6
- package/dist/run/daemon.js +275 -22
- package/dist/run/journal.d.ts +16 -0
- package/dist/run/journal.js +51 -6
- package/dist/tui/cockpit/setup-cockpit.d.ts +8 -10
- package/dist/tui/cockpit/setup-cockpit.js +92 -53
- package/package.json +1 -1
- package/skills/tickmarkr-auto/SKILL.md +1 -1
- package/skills/tickmarkr-loop/SKILL.md +1 -1
- package/skills/tickmarkr-overseer/SKILL.md +202 -24
- package/skills/tickmarkr-overseer/scripts/watch-context.sh +109 -4
- package/skills/tickmarkr-overseer/scripts/watch-journal.sh +137 -0
package/dist/run/daemon.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
1
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
3
|
import { shq } from "../adapters/types.js";
|
|
3
|
-
import { appendFileSync, closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { appendFileSync, closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, readSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
5
|
import { tmpdir } from "node:os";
|
|
5
|
-
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
|
|
6
7
|
import { stringify } from "yaml";
|
|
7
8
|
import { classifyDeadChannel, NO_TRAILER_SUMMARY, trailerPattern, UNPARSEABLE_TRAILER_SUMMARY, writePrompt } from "../adapters/prompt.js";
|
|
8
9
|
import { allAdapters, getAdapter, probeAll, readDoctor, rolePools } from "../adapters/registry.js";
|
|
@@ -15,13 +16,14 @@ import { herdrSealShellPrefix, SubprocessDriver } from "../drivers/subprocess.js
|
|
|
15
16
|
import { formatOwnedName } from "../drivers/types.js";
|
|
16
17
|
import { captureBaseline, detectGateCommands, detectVacuousOracles } from "../gates/baseline.js";
|
|
17
18
|
import { runGates } from "../gates/run-gates.js";
|
|
19
|
+
import { filesGlob } from "../graph/files-glob.js";
|
|
18
20
|
import { addEvidence, attributeBlocked, blockedTasks, getTask, graphDefinitionHash, loadGraph, pendingTasks, readyTasks, saveGraph, setStatus, taskContentDigest, tickmarkrDir } from "../graph/graph.js";
|
|
19
21
|
import { GATE_NAMES } from "../graph/schema.js";
|
|
20
22
|
import { augmentRetryBrief, consult, renderRetryGuidance } from "./consult.js";
|
|
21
23
|
import { runEnvironment } from "./environment.js";
|
|
22
24
|
import { cleanupRunWorktrees, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, preserveWorktree, resolvedCapacity, runWithForkBudget, sameCapacity, sh, shGit, WORKTREE_LAYOUT_CONTRACT, worktreePath } from "./git.js";
|
|
23
25
|
import { runInteractiveSeed } from "./interactive-seed.js";
|
|
24
|
-
import { activeRetryBan, classifyTaskFailure, classifyWorkerResultCause, deferredReviewFindings, engagementComparable, formatPriorFindingEvidence, GATE_FINGERPRINT_CAP, GATE_SATISFIED_RELEASE, identicalGateFailures, isDeferredFinding, journaledFailureBrief, Journal, loadRoutingProfile, newRunId, normalizeGateFailure, outstandingReviewFindings, pendingRepairFindings, phaseForGate, readPriorRunEvidence, recordedTaskFailureKind, renderStructuredReviewFinding, repairsSinceApproval, reviewRoundsSinceApproval, runHasEnded, structuredFindings, upheldFeedbackByTask } from "./journal.js";
|
|
26
|
+
import { activeRetryBan, classifyTaskFailure, classifyWorkerResultCause, deferredReviewFindings, engagementComparable, formatPriorFindingEvidence, GATE_FINGERPRINT_CAP, GATE_SATISFIED_RELEASE, identicalGateFailures, isDeferredFinding, journaledFailureBrief, Journal, loadRoutingProfile, newRunId, normalizeGateFailure, outstandingConsultGuidance, outstandingReviewFindings, pendingRepairFindings, phaseForGate, readPriorRunEvidence, recordedTaskFailureKind, renderStructuredReviewFinding, repairsSinceApproval, reviewRoundsSinceApproval, runHasEnded, structuredFindings, upheldFeedbackByTask } from "./journal.js";
|
|
25
27
|
import { isDiffCapPark } from "../gates/review.js";
|
|
26
28
|
import { acquireApprovalSerialization, acquireRunLock, isPidLive, releaseRunLock } from "./lock.js";
|
|
27
29
|
import { ensureIntegration, integrationBranch, integrationHead, mergeTask, verifyIntegrationTip } from "./merge.js";
|
|
@@ -76,12 +78,16 @@ const DISPATCH_ENACTMENT = new Set(["task-dispatch", "repair-dispatch"]);
|
|
|
76
78
|
// for the task is worktree-recreation. That event is causal for this path, not incidental.
|
|
77
79
|
const GATE_SATISFIED_ENACTMENT = "worktree-recreation";
|
|
78
80
|
/**
|
|
79
|
-
* T14: approvals the run accepted and never acted on. `approved` above is
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
81
|
+
* T14, amended by v2.2 T3: approvals the run accepted and never acted on. `approved` above is still
|
|
82
|
+
* built ONCE at startup — replay determinism depends on it — but a live approval is no longer inert:
|
|
83
|
+
* the boundary sweep in the task loop releases what lands while the daemon runs, so an approval
|
|
84
|
+
* written mid-run is normally enacted by this run. ONE window survives, and it is the reason this
|
|
85
|
+
* fold still exists: an approval accepted after the task loop exits — during tip verify, before the
|
|
86
|
+
* run-end sample below — meets no further boundary, so nothing can release it before this run ends.
|
|
87
|
+
* Without this the run-end record stated only buckets and tipVerify, both accurate, over a milestone
|
|
88
|
+
* that was silently incomplete: run …230 ended tipVerify "passed" with two upheld approvals and zero
|
|
89
|
+
* subsequent dispatches. Scored per task on its NEWEST approval: a later approval is the live
|
|
90
|
+
* decision, and the events that answer it are the ones after it.
|
|
85
91
|
*/
|
|
86
92
|
export function outstandingApprovals(events) {
|
|
87
93
|
const newest = new Map();
|
|
@@ -687,6 +693,202 @@ function compareWorktrees(before, after) {
|
|
|
687
693
|
return "unreadable";
|
|
688
694
|
return before.signature === after.signature ? "unchanged" : "changed";
|
|
689
695
|
}
|
|
696
|
+
const CONTEXT_GLOB_CHARS = /[*?{[]/;
|
|
697
|
+
const MATERIALIZED_CONTEXT_BYTES = 256 * 1024 * 1024;
|
|
698
|
+
function containedIn(root, path) {
|
|
699
|
+
const fromRoot = relative(resolve(root), resolve(path));
|
|
700
|
+
return fromRoot === "" || (fromRoot !== ".." && !fromRoot.startsWith(`..${sep}`) && !isAbsolute(fromRoot));
|
|
701
|
+
}
|
|
702
|
+
function normalizeContextPath(entry) {
|
|
703
|
+
if (entry.includes("\0"))
|
|
704
|
+
return undefined;
|
|
705
|
+
const repositoryPath = entry.replace(/\\/g, "/");
|
|
706
|
+
if (posix.isAbsolute(repositoryPath) || repositoryPath.split("/").includes(".."))
|
|
707
|
+
return undefined;
|
|
708
|
+
const normalized = posix.normalize(repositoryPath).replace(/\/+$/, "");
|
|
709
|
+
if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../"))
|
|
710
|
+
return undefined;
|
|
711
|
+
return normalized;
|
|
712
|
+
}
|
|
713
|
+
function workerFilesystemEntries(worktree) {
|
|
714
|
+
const entries = [];
|
|
715
|
+
const visit = (dir, prefix) => {
|
|
716
|
+
let dirents;
|
|
717
|
+
try {
|
|
718
|
+
dirents = readdirSync(dir, { withFileTypes: true });
|
|
719
|
+
}
|
|
720
|
+
catch (error) {
|
|
721
|
+
const rel = relative(worktree, dir).split(sep).join(posix.sep) || ".";
|
|
722
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
723
|
+
throw new Error(`context filesystem observation failed at ${rel}: ${message}`);
|
|
724
|
+
}
|
|
725
|
+
for (const dirent of dirents) {
|
|
726
|
+
if (dirent.name === ".git")
|
|
727
|
+
continue;
|
|
728
|
+
const rel = prefix ? `${prefix}/${dirent.name}` : dirent.name;
|
|
729
|
+
entries.push(rel);
|
|
730
|
+
if (dirent.isDirectory())
|
|
731
|
+
visit(join(dir, dirent.name), rel);
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
visit(worktree, "");
|
|
735
|
+
return entries;
|
|
736
|
+
}
|
|
737
|
+
function exactContextResolvesInWorktree(entry, normalized, worktree) {
|
|
738
|
+
const fullPath = join(worktree, normalized);
|
|
739
|
+
try {
|
|
740
|
+
const stat = lstatSync(fullPath);
|
|
741
|
+
if (stat.isSymbolicLink())
|
|
742
|
+
return false;
|
|
743
|
+
if (stat.isDirectory())
|
|
744
|
+
return true;
|
|
745
|
+
return !/\/+$/.test(entry) && stat.isFile() && worktreeHeadHasRegularBlob(worktree, normalized);
|
|
746
|
+
}
|
|
747
|
+
catch (error) {
|
|
748
|
+
if (["EACCES", "ELOOP", "ENOENT", "ENOTDIR", "EPERM"].includes(error.code ?? ""))
|
|
749
|
+
return false;
|
|
750
|
+
throw error;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
function patternContextResolvesInWorktree(normalized, worktree) {
|
|
754
|
+
const match = filesGlob(normalized);
|
|
755
|
+
return workerFilesystemEntries(worktree).some((path) => match(path));
|
|
756
|
+
}
|
|
757
|
+
function regularBlobObjectId(repoRoot, ref, normalized) {
|
|
758
|
+
let tree = "";
|
|
759
|
+
try {
|
|
760
|
+
tree = execFileSync("git", ["-C", repoRoot, "ls-tree", "-z", ref, "--", normalized], {
|
|
761
|
+
encoding: "utf8",
|
|
762
|
+
maxBuffer: 1024 * 1024,
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
catch {
|
|
766
|
+
return undefined;
|
|
767
|
+
}
|
|
768
|
+
const row = tree.split("\0")
|
|
769
|
+
.filter(Boolean)
|
|
770
|
+
.map((line) => /^(\d{6}) (\w+) ([0-9a-f]+)\t([\s\S]*)$/.exec(line))
|
|
771
|
+
.find((match) => match?.[4] === normalized);
|
|
772
|
+
if (!row || row[2] !== "blob" || (row[1] !== "100644" && row[1] !== "100755"))
|
|
773
|
+
return undefined;
|
|
774
|
+
return row[3];
|
|
775
|
+
}
|
|
776
|
+
function worktreeHeadHasRegularBlob(worktree, normalized) {
|
|
777
|
+
return regularBlobObjectId(worktree, "HEAD", normalized) !== undefined;
|
|
778
|
+
}
|
|
779
|
+
function committedRegularBlob(repoRoot, normalized) {
|
|
780
|
+
const objectId = regularBlobObjectId(repoRoot, "HEAD", normalized);
|
|
781
|
+
if (!objectId)
|
|
782
|
+
return undefined;
|
|
783
|
+
try {
|
|
784
|
+
const bytes = execFileSync("git", ["-C", repoRoot, "cat-file", "blob", objectId], {
|
|
785
|
+
encoding: "buffer",
|
|
786
|
+
maxBuffer: MATERIALIZED_CONTEXT_BYTES,
|
|
787
|
+
});
|
|
788
|
+
return { bytes };
|
|
789
|
+
}
|
|
790
|
+
catch {
|
|
791
|
+
return undefined;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
function materializedContextDestination(root, task, attempt, normalized) {
|
|
795
|
+
const key = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
|
|
796
|
+
const leaf = basename(normalized).replace(/[^A-Za-z0-9._-]/g, "_") || "context";
|
|
797
|
+
const destination = join(root, task.id, `a${attempt}`, `${key}-${leaf}`);
|
|
798
|
+
if (!containedIn(root, destination))
|
|
799
|
+
throw new Error(`materialized context destination escapes context subtree: ${normalized}`);
|
|
800
|
+
return destination;
|
|
801
|
+
}
|
|
802
|
+
function ensureDirectoryWithoutSymlink(path) {
|
|
803
|
+
let stat;
|
|
804
|
+
try {
|
|
805
|
+
stat = lstatSync(path);
|
|
806
|
+
}
|
|
807
|
+
catch (error) {
|
|
808
|
+
if (error.code !== "ENOENT")
|
|
809
|
+
throw error;
|
|
810
|
+
mkdirSync(path, { mode: 0o700 });
|
|
811
|
+
stat = lstatSync(path);
|
|
812
|
+
}
|
|
813
|
+
if (stat.isSymbolicLink())
|
|
814
|
+
throw new Error(`materialized context destination ancestor is a symlink: ${path}`);
|
|
815
|
+
if (!stat.isDirectory())
|
|
816
|
+
throw new Error(`materialized context destination ancestor is not a directory: ${path}`);
|
|
817
|
+
}
|
|
818
|
+
function ensureContainedContextParent(root, destination) {
|
|
819
|
+
const rootAbs = resolve(root);
|
|
820
|
+
const destAbs = resolve(destination);
|
|
821
|
+
if (!containedIn(rootAbs, destAbs))
|
|
822
|
+
throw new Error("materialized context destination escapes context subtree");
|
|
823
|
+
const parent = dirname(destAbs);
|
|
824
|
+
ensureDirectoryWithoutSymlink(rootAbs);
|
|
825
|
+
const relParent = relative(rootAbs, parent);
|
|
826
|
+
let current = rootAbs;
|
|
827
|
+
for (const segment of relParent.split(sep).filter(Boolean)) {
|
|
828
|
+
current = join(current, segment);
|
|
829
|
+
ensureDirectoryWithoutSymlink(current);
|
|
830
|
+
}
|
|
831
|
+
if (!containedIn(realpathSync(rootAbs), realpathSync(parent))) {
|
|
832
|
+
throw new Error("materialized context destination realpath escapes context subtree");
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
function writeContainedContextFile(root, destination, bytes) {
|
|
836
|
+
if (!containedIn(root, destination))
|
|
837
|
+
throw new Error("materialized context destination escapes context subtree");
|
|
838
|
+
ensureContainedContextParent(root, destination);
|
|
839
|
+
const fd = openSync(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, 0o600);
|
|
840
|
+
try {
|
|
841
|
+
writeFileSync(fd, bytes);
|
|
842
|
+
}
|
|
843
|
+
finally {
|
|
844
|
+
closeSync(fd);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
async function materializeContextEntry(repoRoot, worktree, contextRoot, task, attempt, entry) {
|
|
848
|
+
const normalized = normalizeContextPath(entry);
|
|
849
|
+
if (!normalized)
|
|
850
|
+
return { kind: "missing", path: entry, reason: "invalid-path" };
|
|
851
|
+
if (exactContextResolvesInWorktree(entry, normalized, worktree))
|
|
852
|
+
return { kind: "keep", entry };
|
|
853
|
+
const blob = committedRegularBlob(repoRoot, normalized);
|
|
854
|
+
if (blob) {
|
|
855
|
+
const destination = materializedContextDestination(contextRoot, task, attempt, normalized);
|
|
856
|
+
writeContainedContextFile(contextRoot, destination, blob.bytes);
|
|
857
|
+
return { kind: "materialized", entry: destination, normalized, destination, bytes: blob.bytes.length };
|
|
858
|
+
}
|
|
859
|
+
if (CONTEXT_GLOB_CHARS.test(entry)) {
|
|
860
|
+
if (patternContextResolvesInWorktree(normalized, worktree))
|
|
861
|
+
return { kind: "keep", entry };
|
|
862
|
+
return { kind: "missing", path: entry, reason: "unresolved-pattern" };
|
|
863
|
+
}
|
|
864
|
+
if (/\/+$/.test(entry))
|
|
865
|
+
return { kind: "missing", path: entry, reason: "unresolved-pattern" };
|
|
866
|
+
return { kind: "missing", path: entry, reason: "no-committed-regular-blob" };
|
|
867
|
+
}
|
|
868
|
+
async function taskWithMaterializedContext(repoRoot, journal, worktree, task, attempt) {
|
|
869
|
+
if (task.context.length === 0)
|
|
870
|
+
return task;
|
|
871
|
+
const contextRoot = resolve(journal.dir, "context");
|
|
872
|
+
const context = [];
|
|
873
|
+
for (const entry of task.context) {
|
|
874
|
+
const prepared = await materializeContextEntry(repoRoot, worktree, contextRoot, task, attempt, entry);
|
|
875
|
+
if (prepared.kind === "missing") {
|
|
876
|
+
journal.append("context-missing", task.id, { path: prepared.path, reason: prepared.reason });
|
|
877
|
+
}
|
|
878
|
+
else {
|
|
879
|
+
context.push(prepared.entry);
|
|
880
|
+
if (prepared.kind === "materialized") {
|
|
881
|
+
journal.append("context-materialized", task.id, {
|
|
882
|
+
path: entry,
|
|
883
|
+
normalized: prepared.normalized,
|
|
884
|
+
destination: prepared.destination,
|
|
885
|
+
bytes: prepared.bytes,
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
return { ...task, context };
|
|
891
|
+
}
|
|
690
892
|
async function cherryPickCommits(wt, commits) {
|
|
691
893
|
const carried = [];
|
|
692
894
|
for (const hash of commits) {
|
|
@@ -975,10 +1177,11 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
975
1177
|
journal.append("lock-reclaimed", undefined, lock.reclaimed); // HARD-02 audit trail
|
|
976
1178
|
}
|
|
977
1179
|
// GATE-08 (v1.12): the humanGate guard consults this run's journaled approvals, not just the compiled
|
|
978
|
-
// flag.
|
|
979
|
-
//
|
|
980
|
-
|
|
981
|
-
const approved = new Set(
|
|
1180
|
+
// flag. Startup approvals seed the first scheduling pass; live approvals are folded at task
|
|
1181
|
+
// boundaries below so a sibling can release parked work without waiting for run-end + resume.
|
|
1182
|
+
const approvalStartupEvents = journal.read();
|
|
1183
|
+
const approved = new Set(approvalStartupEvents.filter((e) => e.event === "task-approved" && e.taskId).map((e) => e.taskId));
|
|
1184
|
+
let approvalSweepCursor = approvalStartupEvents.length;
|
|
982
1185
|
const commands = detectGateCommands(repoRoot, cfg);
|
|
983
1186
|
let baseRef;
|
|
984
1187
|
let baseline;
|
|
@@ -986,9 +1189,9 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
986
1189
|
// Empty Map on fresh runs — every seed below conditions on resume.get(t.id), never on opts.resume (the
|
|
987
1190
|
// GATE-08 lesson at the humanGate guard: condition on the data, not the code path). Dead-code
|
|
988
1191
|
// equivalence to the router.ts:194 profile⇒undefined pattern: no map entry ⇒ today's literal.
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1192
|
+
let resume = opts.resume ? journal.replayResumeState() : new Map();
|
|
1193
|
+
let satisfiedGates = opts.resume ? journal.replaySatisfiedGates() : new Map();
|
|
1194
|
+
let replayedGateResults = resumeLifecycleOpen
|
|
992
1195
|
? journal.replayCurrentAttemptGateResults()
|
|
993
1196
|
: new Map();
|
|
994
1197
|
// T7: the capacity THIS session resolved — read inside the run's fork budget, so it is the number
|
|
@@ -1093,8 +1296,8 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1093
1296
|
try {
|
|
1094
1297
|
watchSlot = await driver.narrator?.(repoRoot, watchCommand(runId), runId);
|
|
1095
1298
|
}
|
|
1096
|
-
catch {
|
|
1097
|
-
|
|
1299
|
+
catch (error) {
|
|
1300
|
+
journal.append("watch-placement-failed", undefined, { error: error instanceof Error ? error.message : String(error) });
|
|
1098
1301
|
}
|
|
1099
1302
|
const intWt = await ensureIntegration(repoRoot, branch, baseRef);
|
|
1100
1303
|
// v1.1 visibility: role-named slots; panes persist per keepPanes (attempt = v1 close-after-harvest)
|
|
@@ -1169,6 +1372,14 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1169
1372
|
};
|
|
1170
1373
|
// gateFails/consults are execTask-scoped counters passed in so a park row is a rich verified-failure
|
|
1171
1374
|
// observation (e.g. ladder-exhausted + gateFails:4); every task-human row has a closed kind, never prose alone.
|
|
1375
|
+
const gateFailApprovalReason = (taskId, identity, includeUphold = false) => {
|
|
1376
|
+
const commands = [
|
|
1377
|
+
`tickmarkr approve ${runId} ${taskId} --waive`,
|
|
1378
|
+
`tickmarkr approve ${runId} ${taskId} --recheck`,
|
|
1379
|
+
...(includeUphold ? [`tickmarkr approve ${runId} ${taskId} --uphold`] : []),
|
|
1380
|
+
];
|
|
1381
|
+
return `${identity} — release with ${commands.map((command) => `\`${command}\``).join(" or ")}`;
|
|
1382
|
+
};
|
|
1172
1383
|
const park = async (t, reason, kind, assignment, attempts, startMs, gateFails = 0, consults = 0, tokens, metered = 0, retryMode = "fresh", details = {}) => {
|
|
1173
1384
|
graph = setStatus(graph, t.id, "human");
|
|
1174
1385
|
saveGraph(repoRoot, graph);
|
|
@@ -1228,6 +1439,38 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1228
1439
|
}
|
|
1229
1440
|
return false;
|
|
1230
1441
|
};
|
|
1442
|
+
const sweepLiveApprovals = () => {
|
|
1443
|
+
const events = journal.read();
|
|
1444
|
+
const approvals = events.slice(approvalSweepCursor)
|
|
1445
|
+
.filter((e) => e.event === "task-approved" && e.taskId);
|
|
1446
|
+
approvalSweepCursor = events.length;
|
|
1447
|
+
if (approvals.length === 0)
|
|
1448
|
+
return;
|
|
1449
|
+
resume = journal.replayResumeState();
|
|
1450
|
+
satisfiedGates = journal.replaySatisfiedGates();
|
|
1451
|
+
replayedGateResults = resumeLifecycleOpen
|
|
1452
|
+
? journal.replayCurrentAttemptGateResults()
|
|
1453
|
+
: new Map();
|
|
1454
|
+
let changed = false;
|
|
1455
|
+
for (const e of approvals) {
|
|
1456
|
+
const taskId = e.taskId;
|
|
1457
|
+
approved.add(taskId);
|
|
1458
|
+
if (e.data.release !== GATE_SATISFIED_RELEASE)
|
|
1459
|
+
replayedGateResults.delete(taskId);
|
|
1460
|
+
try {
|
|
1461
|
+
const task = getTask(graph, taskId);
|
|
1462
|
+
if (task.status === "human" || task.status === "failed") {
|
|
1463
|
+
graph = setStatus(graph, taskId, "pending");
|
|
1464
|
+
changed = true;
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
catch {
|
|
1468
|
+
// Unknown task in a stale journal: approval replay is inert, like the other journal folds.
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
if (changed)
|
|
1472
|
+
saveGraph(repoRoot, graph);
|
|
1473
|
+
};
|
|
1231
1474
|
const execTask = async (t) => {
|
|
1232
1475
|
const startMs = Date.now();
|
|
1233
1476
|
const taskTimeoutMinutes = t.timeoutMinutes ?? cfg.taskTimeoutMinutes;
|
|
@@ -1801,7 +2044,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1801
2044
|
// feedback from those rows. Only an operator-authorized gate release parks on a new red.
|
|
1802
2045
|
if (!satisfiedGate)
|
|
1803
2046
|
break gateLoop;
|
|
1804
|
-
await park(t, "post-approval gate failed", "gate-fail", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
|
|
2047
|
+
await park(t, gateFailApprovalReason(t.id, "post-approval gate failed", results.some((g) => g.gate === "review" && gateFailed(g))), "gate-fail", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
|
|
1805
2048
|
return;
|
|
1806
2049
|
}
|
|
1807
2050
|
const m = await mergeSerial(taskBranch, t, gated);
|
|
@@ -1859,7 +2102,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1859
2102
|
// survives the reset and governs only those further rounds. Absence preserves the module default.
|
|
1860
2103
|
const reviewRoundCap = approvedReviewRoundCeiling(journal.read(), t.id) ?? REVIEW_ROUND_CAP;
|
|
1861
2104
|
if (attempt > 0 && reviewRoundsDrawn() >= reviewRoundCap) {
|
|
1862
|
-
await park(t, `review round cap (${reviewRoundCap}) reached this engagement
|
|
2105
|
+
await park(t, gateFailApprovalReason(t.id, `review round cap (${reviewRoundCap}) reached this engagement`, true), "gate-fail", assignment, attempt, startMs, gateFails, consults, tokens, metered, retryMode);
|
|
1863
2106
|
return;
|
|
1864
2107
|
}
|
|
1865
2108
|
// OBS-57: a demoted channel must not be re-dispatched on consult retry or provider requeue.
|
|
@@ -1895,7 +2138,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1895
2138
|
gate: banned, from: channelKey(assignment), to: next ? channelKey(next) : null,
|
|
1896
2139
|
});
|
|
1897
2140
|
if (!next) {
|
|
1898
|
-
await park(t, `identical ${banned} failure twice this engagement — an identical retry is banned and no untried channel is left
|
|
2141
|
+
await park(t, gateFailApprovalReason(t.id, `identical ${banned} failure twice this engagement — an identical retry is banned and no untried channel is left`), "gate-fail", assignment, attempt, startMs, gateFails, consults, tokens, metered, retryMode);
|
|
1899
2142
|
return;
|
|
1900
2143
|
}
|
|
1901
2144
|
assignment = next;
|
|
@@ -1964,6 +2207,13 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1964
2207
|
})].join("\n");
|
|
1965
2208
|
feedback = feedback ? `${feedback}\n\n${brief}` : brief;
|
|
1966
2209
|
}
|
|
2210
|
+
const carriedConsultGuidance = outstandingConsultGuidance(journaledSoFar, t.id);
|
|
2211
|
+
if (carriedConsultGuidance) {
|
|
2212
|
+
const consultBrief = renderRetryGuidance({ ...carriedConsultGuidance, notes: "" });
|
|
2213
|
+
if (consultBrief && !feedback.includes(consultBrief)) {
|
|
2214
|
+
feedback = feedback ? `${feedback}\n\n${consultBrief}` : consultBrief;
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
1967
2217
|
retryMode = repairFindings
|
|
1968
2218
|
? "repair"
|
|
1969
2219
|
: priorSession
|
|
@@ -1992,6 +2242,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1992
2242
|
journal.append("task-dispatch", t.id, {
|
|
1993
2243
|
assignment, attempt, provenance: dispatchProvenance(r.provenance), retryMode,
|
|
1994
2244
|
...(outstandingFindings.length > 0 ? { carriedFindings: outstandingFindings } : {}),
|
|
2245
|
+
...(carriedConsultGuidance ? { carriedConsultGuidance } : {}),
|
|
1995
2246
|
});
|
|
1996
2247
|
journal.phaseStart(t.id, "worker", { attempt, assignment });
|
|
1997
2248
|
const taskBase = await integrationHead(intWt); // deps are merged → visible to this task
|
|
@@ -2091,7 +2342,8 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2091
2342
|
return;
|
|
2092
2343
|
}
|
|
2093
2344
|
}
|
|
2094
|
-
const
|
|
2345
|
+
const promptTask = await taskWithMaterializedContext(repoRoot, journal, wt, t, attempt);
|
|
2346
|
+
const promptFile = writePrompt(journal.dir, promptTask, attempt, feedback, nonce);
|
|
2095
2347
|
// OBS-56: state the non-interactive, one-pass finish contract and the OBS-54 stall budget in every
|
|
2096
2348
|
// worker prompt, not only consult retry guidance. Prepended so prompt.ts's completion trailer stays last.
|
|
2097
2349
|
const workerContract = `## Harness contract\n- This harness is non-interactive: make one continuous pass; do not stop for questions or follow-up input.\n- You have a ${taskTimeoutMinutes} minute stall window. The gates run the full suite for you — never spend this window on one; commit your work and emit the completion trailer inside it.\n- Each test: acceptance criterion must exist as a vitest test whose OWN title (the leaf, not counting enclosing describe titles) is the criterion string verbatim — never shortened, never decorated. Nesting under describe() is allowed.`; // OBS-64; OBS-511: leaf-title rule stated where the worker reads it; OBS-548: the suite is the GATES' job — this repo's own suite outlasts the output-silence windows the daemon polices, so a worker obeying "budget the full suite" was killed by construction
|
|
@@ -3465,6 +3717,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3465
3717
|
// must still stop the run before it can dispatch more work or write run-end.
|
|
3466
3718
|
if (termSignal)
|
|
3467
3719
|
throw new Error(`terminated by ${termSignal}`);
|
|
3720
|
+
sweepLiveApprovals();
|
|
3468
3721
|
const ready = readyTasks(graph)
|
|
3469
3722
|
.filter((t) => !inflight.has(t.id))
|
|
3470
3723
|
.slice(0, Math.max(0, concurrency - inflight.size));
|
package/dist/run/journal.d.ts
CHANGED
|
@@ -44,6 +44,21 @@ export interface StructuredFinding {
|
|
|
44
44
|
rationale?: string;
|
|
45
45
|
fingerprint: string;
|
|
46
46
|
}
|
|
47
|
+
declare const CONSULT_ACTIONS: readonly ["retry", "reroute", "decompose", "human"];
|
|
48
|
+
export type ConsultGuidanceAction = (typeof CONSULT_ACTIONS)[number];
|
|
49
|
+
export interface ConsultGuidanceCarry {
|
|
50
|
+
action: ConsultGuidanceAction;
|
|
51
|
+
reason?: string;
|
|
52
|
+
guidance?: string;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The newest structured consult prescription still open for this task. A consult park is only a
|
|
56
|
+
* pause: graph-changed revival can dispatch a fresh worker without a live `applyVerdict()` frame, so
|
|
57
|
+
* prompt-build re-derives the worker-facing bullets from the journal. Notes are deliberately excluded:
|
|
58
|
+
* they are audit prose, and OBS-37a says workers get only structured reason/guidance rendered by
|
|
59
|
+
* `renderRetryGuidance()`.
|
|
60
|
+
*/
|
|
61
|
+
export declare function outstandingConsultGuidance(events: JournalEvent[], taskId: string): ConsultGuidanceCarry | undefined;
|
|
47
62
|
export declare const UNIDENTIFIED = "<unidentified>";
|
|
48
63
|
/**
|
|
49
64
|
* Structured findings for a BLOCKING review/judge gate result, parsed from the details the gate
|
|
@@ -335,3 +350,4 @@ export declare class Journal {
|
|
|
335
350
|
readTelemetry(): TelemetryRow[];
|
|
336
351
|
readJudgeTelemetry(): TelemetryRow[];
|
|
337
352
|
}
|
|
353
|
+
export {};
|
package/dist/run/journal.js
CHANGED
|
@@ -148,6 +148,40 @@ export function upheldFeedbackByTask(events) {
|
|
|
148
148
|
}
|
|
149
149
|
return upheld;
|
|
150
150
|
}
|
|
151
|
+
const CONSULT_ACTIONS = ["retry", "reroute", "decompose", "human"];
|
|
152
|
+
const consultAction = (action) => typeof action === "string" && CONSULT_ACTIONS.includes(action)
|
|
153
|
+
? action
|
|
154
|
+
: undefined;
|
|
155
|
+
/**
|
|
156
|
+
* The newest structured consult prescription still open for this task. A consult park is only a
|
|
157
|
+
* pause: graph-changed revival can dispatch a fresh worker without a live `applyVerdict()` frame, so
|
|
158
|
+
* prompt-build re-derives the worker-facing bullets from the journal. Notes are deliberately excluded:
|
|
159
|
+
* they are audit prose, and OBS-37a says workers get only structured reason/guidance rendered by
|
|
160
|
+
* `renderRetryGuidance()`.
|
|
161
|
+
*/
|
|
162
|
+
export function outstandingConsultGuidance(events, taskId) {
|
|
163
|
+
let carry;
|
|
164
|
+
for (const e of events) {
|
|
165
|
+
if (e.taskId !== taskId)
|
|
166
|
+
continue;
|
|
167
|
+
if (e.event === "consult-verdict") {
|
|
168
|
+
const action = consultAction(e.data.action);
|
|
169
|
+
const reason = typeof e.data.reason === "string" && e.data.reason.trim() ? e.data.reason : undefined;
|
|
170
|
+
const guidance = typeof e.data.guidance === "string" && e.data.guidance.trim() ? e.data.guidance : undefined;
|
|
171
|
+
carry = action && (reason || guidance)
|
|
172
|
+
? {
|
|
173
|
+
action,
|
|
174
|
+
...(reason ? { reason } : {}),
|
|
175
|
+
...(guidance ? { guidance } : {}),
|
|
176
|
+
}
|
|
177
|
+
: undefined;
|
|
178
|
+
}
|
|
179
|
+
else if (e.event === "task-done" || e.event === "merge") {
|
|
180
|
+
carry = undefined;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return carry;
|
|
184
|
+
}
|
|
151
185
|
// Reserved for a finding whose OWN evidence names no path. Reporting a blank path a reader would take
|
|
152
186
|
// for a resolved one is the silent-lie shape the gates exist to refuse, so the field says so outright.
|
|
153
187
|
export const UNIDENTIFIED = "<unidentified>";
|
|
@@ -1360,16 +1394,27 @@ export class Journal {
|
|
|
1360
1394
|
}
|
|
1361
1395
|
return m;
|
|
1362
1396
|
}
|
|
1363
|
-
// OBS-130: gate satisfaction is authority, not an inferred daemon state. Only an explicit
|
|
1364
|
-
// task-approved event with the typed release marker and a known gate enters this fold.
|
|
1365
|
-
//
|
|
1397
|
+
// OBS-130/OBS-571: gate satisfaction is authority, not an inferred daemon state. Only an explicit
|
|
1398
|
+
// task-approved event with the typed release marker and a known gate enters this fold. Authority is
|
|
1399
|
+
// scoped to that approval: any later approval supersedes it, and the worktree-recreation row written
|
|
1400
|
+
// before the approved gate suffix runs consumes it at enactment. A daemon-made gate-satisfied event,
|
|
1401
|
+
// a prior pass/fail result, malformed data, or another task's approval is inert.
|
|
1366
1402
|
replaySatisfiedGates() {
|
|
1367
1403
|
const satisfied = new Map();
|
|
1368
1404
|
for (const e of this.read()) {
|
|
1369
|
-
if (
|
|
1405
|
+
if (!e.taskId)
|
|
1370
1406
|
continue;
|
|
1371
|
-
if (
|
|
1372
|
-
satisfied.
|
|
1407
|
+
if (e.event === "worktree-recreation") {
|
|
1408
|
+
satisfied.delete(e.taskId);
|
|
1409
|
+
continue;
|
|
1410
|
+
}
|
|
1411
|
+
if (e.event === "task-approved") {
|
|
1412
|
+
satisfied.delete(e.taskId);
|
|
1413
|
+
if (e.data.release === GATE_SATISFIED_RELEASE
|
|
1414
|
+
&& typeof e.data.gate === "string"
|
|
1415
|
+
&& GATE_NAMES.includes(e.data.gate)) {
|
|
1416
|
+
satisfied.set(e.taskId, e.data.gate);
|
|
1417
|
+
}
|
|
1373
1418
|
}
|
|
1374
1419
|
}
|
|
1375
1420
|
return satisfied;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ReactElement } from "react";
|
|
2
|
+
import { type ApprovalDisposition, type ApprovalRunOwner } from "../../cli/commands/approve.js";
|
|
2
3
|
import { Journal } from "../../run/journal.js";
|
|
3
4
|
import { type TickmarkrConfig } from "../../config/config.js";
|
|
4
5
|
import { type ComponentState, type JournalRow } from "./components.js";
|
|
@@ -73,15 +74,8 @@ export type ParkedDecision = {
|
|
|
73
74
|
};
|
|
74
75
|
/** The parks a verb can release — the rows the surface draws live verbs on. */
|
|
75
76
|
export declare function actionableDecisions(decisions: readonly ParkedDecision[]): readonly ParkedDecision[];
|
|
76
|
-
/**
|
|
77
|
-
* The decisions section's rows: one per task the journal's own status replay
|
|
78
|
-
* leaves parked on a human, in the order the journal first named them. The
|
|
79
|
-
* replay is the production one (`Journal.replayStatuses`) so this surface and
|
|
80
|
-
* `tickmarkr status` can never disagree about what is parked.
|
|
81
|
-
*/
|
|
82
77
|
export declare function deriveParkedDecisions(journal: Journal): readonly ParkedDecision[];
|
|
83
|
-
|
|
84
|
-
export type SetupDecisionVerb = "approve" | "uphold";
|
|
78
|
+
export type SetupDecisionVerb = "approve" | "waive" | "uphold" | "recheck";
|
|
85
79
|
/** A decision the operator has named but not yet confirmed. */
|
|
86
80
|
export type SetupDecisionCommand = {
|
|
87
81
|
readonly verb: SetupDecisionVerb;
|
|
@@ -93,6 +87,7 @@ export type SetupDecisionWrite = {
|
|
|
93
87
|
readonly verb: SetupDecisionVerb;
|
|
94
88
|
/** The release marker read back from the recorded task-approved event. */
|
|
95
89
|
readonly release: string | undefined;
|
|
90
|
+
readonly disposition: ApprovalDisposition;
|
|
96
91
|
readonly by: string;
|
|
97
92
|
};
|
|
98
93
|
/**
|
|
@@ -120,6 +115,7 @@ export type SetupDecisionsKeyResult = {
|
|
|
120
115
|
* inert and the keybar does not advertise it — the surface never promises a
|
|
121
116
|
* decision the command must refuse.
|
|
122
117
|
*/
|
|
118
|
+
export declare function setupDecisionVerbs(decision: ParkedDecision): readonly SetupDecisionVerb[];
|
|
123
119
|
export declare function upholdAvailable(decision: ParkedDecision): boolean;
|
|
124
120
|
/**
|
|
125
121
|
* The decisions surface's whole key grammar: ↑↓ move, a approve, u uphold,
|
|
@@ -160,7 +156,7 @@ export declare function recordSetupDecisionOutcome(session: SetupDecisionsSessio
|
|
|
160
156
|
* The confirm inset's lines: the task, the actor, the effect and the file,
|
|
161
157
|
* stated before the single key that can write.
|
|
162
158
|
*/
|
|
163
|
-
export declare function setupDecisionConfirmLines(command: SetupDecisionCommand, decision: ParkedDecision | undefined, actor: string, journalFile: string): readonly string[];
|
|
159
|
+
export declare function setupDecisionConfirmLines(command: SetupDecisionCommand, decision: ParkedDecision | undefined, actor: string, journalFile: string, run: ApprovalRunOwner): readonly string[];
|
|
164
160
|
/**
|
|
165
161
|
* The decisions surface. Every row it draws — decisions, the confirm inset,
|
|
166
162
|
* the pending writes region — is measured and clipped through the width
|
|
@@ -168,12 +164,14 @@ export declare function setupDecisionConfirmLines(command: SetupDecisionCommand,
|
|
|
168
164
|
* neither overflow its row nor be cut mid-cluster. The one unclipped element
|
|
169
165
|
* is `notice`: the production command's own words, verbatim.
|
|
170
166
|
*/
|
|
171
|
-
export declare function SetupDecisionsSurface({ decisions, session, columns, actor, journalFile, }: {
|
|
167
|
+
export declare function SetupDecisionsSurface({ decisions, session, columns, actor, journalFile, run, }: {
|
|
172
168
|
decisions: readonly ParkedDecision[];
|
|
173
169
|
session: SetupDecisionsSession;
|
|
174
170
|
columns: number;
|
|
175
171
|
actor: string;
|
|
176
172
|
journalFile: string;
|
|
173
|
+
/** Who enacts a confirmed write — read from the run lock, never assumed. */
|
|
174
|
+
run: ApprovalRunOwner;
|
|
177
175
|
}): ReactElement;
|
|
178
176
|
/**
|
|
179
177
|
* The setup decisions surface on a live engagement. This is the only cockpit
|