@brainervirus/workit-core 8.30.0 → 8.31.1
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/package.json +1 -1
- package/src/core/policy/carry.ts +2 -1
- package/src/hooks/model-switch.ts +35 -0
- package/src/hooks/repeat-failure.ts +115 -0
- package/src/ledger.ts +62 -15
package/package.json
CHANGED
package/src/core/policy/carry.ts
CHANGED
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
import { mergeBase, fileAt } from "../../git/rev";
|
|
18
18
|
import { computeLane, driftFiles, kindOf, parseRiskPaths, RISK_PATHS_FILE } from "./lane";
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
/** `rebase`: the same patch on a moved base (ledger.ts exact carry), never a drift rule. */
|
|
21
|
+
export type CarryRule = "lane-none" | "tests-only" | "rebase";
|
|
21
22
|
|
|
22
23
|
export type Drift =
|
|
23
24
|
| { carries: true; rule: CarryRule; since: string; files: string[] }
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// A mid-session model switch (#435): the new model inherits a long context
|
|
2
|
+
// whose Workit contract sits far up, so the host re-sends the compact contract
|
|
3
|
+
// and the current turn context once per change, never on every turn.
|
|
4
|
+
|
|
5
|
+
/** The last model seen per session, in the host plugin's own state. */
|
|
6
|
+
export type SessionModels = Map<string, string>;
|
|
7
|
+
|
|
8
|
+
const MAX_SESSIONS = 256;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Record `model` for the session and say whether it differs from the last one
|
|
12
|
+
* seen. The first model a session reports is a start, not a switch; an unknown
|
|
13
|
+
* model (null) changes nothing.
|
|
14
|
+
*/
|
|
15
|
+
export const modelSwitched = (
|
|
16
|
+
models: SessionModels,
|
|
17
|
+
sessionId: string,
|
|
18
|
+
model: string | null,
|
|
19
|
+
): boolean => {
|
|
20
|
+
if (!model) return false;
|
|
21
|
+
const previous = models.get(sessionId);
|
|
22
|
+
models.delete(sessionId);
|
|
23
|
+
models.set(sessionId, model);
|
|
24
|
+
if (models.size > MAX_SESSIONS) models.delete(models.keys().next().value!);
|
|
25
|
+
return previous !== undefined && previous !== model;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** `provider/id` from a host's model reference, or null when it names none. */
|
|
29
|
+
export const modelKey = (model: unknown): string | null => {
|
|
30
|
+
if (model === null || typeof model !== "object") return null;
|
|
31
|
+
const { id, providerID, provider } = model as Record<string, unknown>;
|
|
32
|
+
if (typeof id !== "string" || !id) return null;
|
|
33
|
+
const source = typeof providerID === "string" ? providerID : provider;
|
|
34
|
+
return typeof source === "string" && source ? `${source}/${id}` : id;
|
|
35
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// A repeated identical failing Workit call (#435): the second failure in a row
|
|
2
|
+
// of the same tool and args (or the same `workit …` command) in a session ends
|
|
3
|
+
// with one line naming the exact corrected next call, instead of the same error.
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
export const SAME_CALL_FAILED = "Same call failed twice:";
|
|
7
|
+
|
|
8
|
+
/** The last failing call's hash per session, in the host plugin's own state. */
|
|
9
|
+
export type FailureMemo = Map<string, string>;
|
|
10
|
+
|
|
11
|
+
const MAX_SESSIONS = 256;
|
|
12
|
+
|
|
13
|
+
const stable = (value: unknown): string =>
|
|
14
|
+
JSON.stringify(value, (_key, entry: unknown) =>
|
|
15
|
+
entry && typeof entry === "object" && !Array.isArray(entry)
|
|
16
|
+
? Object.fromEntries(Object.entries(entry).toSorted(([a], [b]) => a.localeCompare(b)))
|
|
17
|
+
: entry,
|
|
18
|
+
) ?? "null";
|
|
19
|
+
|
|
20
|
+
const hashOf = (call: string): string =>
|
|
21
|
+
createHash("sha256").update(call).digest("hex").slice(0, 32);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Record this call's outcome for the session. A success clears the memo; a
|
|
25
|
+
* failure returns the escalation line when the previous call failed the same
|
|
26
|
+
* way and a corrected call is known.
|
|
27
|
+
*/
|
|
28
|
+
const record = (
|
|
29
|
+
memo: FailureMemo,
|
|
30
|
+
sessionId: string,
|
|
31
|
+
call: string,
|
|
32
|
+
failed: boolean,
|
|
33
|
+
next: string | null,
|
|
34
|
+
): string | null => {
|
|
35
|
+
const previous = memo.get(sessionId);
|
|
36
|
+
memo.delete(sessionId);
|
|
37
|
+
if (!failed) return null;
|
|
38
|
+
const hash = hashOf(call);
|
|
39
|
+
memo.set(sessionId, hash);
|
|
40
|
+
if (memo.size > MAX_SESSIONS) memo.delete(memo.keys().next().value!);
|
|
41
|
+
return previous === hash && next ? `${SAME_CALL_FAILED} ${next}` : null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type Failed = { ok: false; error: string; details?: { guidance?: unknown } };
|
|
45
|
+
|
|
46
|
+
const isFailure = (result: unknown): result is Failed =>
|
|
47
|
+
result !== null &&
|
|
48
|
+
typeof result === "object" &&
|
|
49
|
+
(result as { ok?: unknown }).ok === false &&
|
|
50
|
+
typeof (result as { error?: unknown }).error === "string";
|
|
51
|
+
|
|
52
|
+
/** A failed tool result's corrected payload: its `Example:` or, failing that, its guidance. */
|
|
53
|
+
const correctedPayload = (tool: string, result: Failed): string | null => {
|
|
54
|
+
const example = /Example: (\{.*\})\s*$/.exec(result.error)?.[1];
|
|
55
|
+
if (example) return `${tool} ${example}`;
|
|
56
|
+
const guidance = result.details?.guidance;
|
|
57
|
+
return typeof guidance === "string" && guidance ? guidance : null;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A Workit tool result, with the escalation line appended to its error when
|
|
62
|
+
* the same tool and args failed on the session's previous call too.
|
|
63
|
+
*/
|
|
64
|
+
export const escalateToolFailure = <T>(
|
|
65
|
+
memo: FailureMemo,
|
|
66
|
+
sessionId: string,
|
|
67
|
+
tool: string,
|
|
68
|
+
input: unknown,
|
|
69
|
+
result: T,
|
|
70
|
+
): T => {
|
|
71
|
+
const failed = isFailure(result);
|
|
72
|
+
const line = record(
|
|
73
|
+
memo,
|
|
74
|
+
sessionId,
|
|
75
|
+
`tool\0${tool}\0${stable(input)}`,
|
|
76
|
+
failed,
|
|
77
|
+
failed ? correctedPayload(tool, result) : null,
|
|
78
|
+
);
|
|
79
|
+
return line && failed ? { ...result, error: `${result.error}\n${line}` } : result;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const WORKIT_COMMAND = /^\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*workit\s/;
|
|
83
|
+
|
|
84
|
+
const unblockOf = (output: string): string | null => {
|
|
85
|
+
const human = /^\s*unblock: (.+)$/m.exec(output)?.[1];
|
|
86
|
+
if (human) return human.trim();
|
|
87
|
+
const json = /"unblock":("(?:[^"\\]|\\.)*")/.exec(output)?.[1];
|
|
88
|
+
if (!json) return null;
|
|
89
|
+
try {
|
|
90
|
+
return JSON.parse(json) as string;
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The escalation line for a finished `workit …` shell command, or null: a
|
|
98
|
+
* failed run is one whose output carries the CLI's error envelope.
|
|
99
|
+
*/
|
|
100
|
+
export const repeatedCliFailure = (
|
|
101
|
+
memo: FailureMemo,
|
|
102
|
+
sessionId: string,
|
|
103
|
+
command: string,
|
|
104
|
+
output: string,
|
|
105
|
+
): string | null => {
|
|
106
|
+
if (!WORKIT_COMMAND.test(command)) return null;
|
|
107
|
+
const failed = /^workit: /m.test(output) || /"ok":\s*false/.test(output);
|
|
108
|
+
return record(
|
|
109
|
+
memo,
|
|
110
|
+
sessionId,
|
|
111
|
+
`cli\0${command.trim()}`,
|
|
112
|
+
failed,
|
|
113
|
+
failed ? unblockOf(output) : null,
|
|
114
|
+
);
|
|
115
|
+
};
|
package/src/ledger.ts
CHANGED
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
// `base`, the stable patch-id of `base...head` and an exact diff hash. It is
|
|
34
34
|
// `fresh` (same head; verdicts on a dirty worktree are refused, so the head
|
|
35
35
|
// is the judged tree), `carried` (new head, same patch-id AND same exact
|
|
36
|
-
// diff AND, for a pass, byte-identical own files the base left alone
|
|
36
|
+
// diff AND, for a pass, byte-identical own files the base left alone, or
|
|
37
|
+
// a head that is exactly the judged head cleanly rebased (cleanRebase): a
|
|
37
38
|
// rebase that only moved the base; or a trusted verdict whose
|
|
38
39
|
// follow-up change is lane none, or test-only for a unit verdict, see
|
|
39
40
|
// core/policy/carry.ts) or `stale`.
|
|
@@ -338,6 +339,32 @@ export function diffHash(cwd: string, base: string, head: string): string | null
|
|
|
338
339
|
return `sha256:${hash.digest("hex")}`;
|
|
339
340
|
}
|
|
340
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Whether `to` (on `toBase`) is exactly the judged `from` (on `fromBase`)
|
|
344
|
+
* rebased cleanly onto the moved base: a 3-way merge of `from` onto
|
|
345
|
+
* `merge-base(toBase, to)` with `merge-base(fromBase, from)` as the ancestor
|
|
346
|
+
* has no conflict and yields `to`'s tree. Hunks are anchored to the
|
|
347
|
+
* ancestor, so an edit moved elsewhere in a file, a conflict resolved by
|
|
348
|
+
* hand, or any other change in the branch's files makes the trees differ.
|
|
349
|
+
* False when git cannot tell (no `merge-tree --merge-base`, before 2.40).
|
|
350
|
+
*/
|
|
351
|
+
function cleanRebase(
|
|
352
|
+
cwd: string,
|
|
353
|
+
range: { from: string; to: string; fromBase: string; toBase: string },
|
|
354
|
+
): boolean {
|
|
355
|
+
if (!Object.values(range).every(safeRef)) return false;
|
|
356
|
+
const fromFork = git(cwd, ["merge-base", range.fromBase, range.from]);
|
|
357
|
+
const toFork = git(cwd, ["merge-base", range.toBase, range.to]);
|
|
358
|
+
if (!fromFork || !toFork || fromFork === toFork) return false;
|
|
359
|
+
const merged = gitRun(
|
|
360
|
+
cwd,
|
|
361
|
+
["merge-tree", "--write-tree", "--no-messages", `--merge-base=${fromFork}`, toFork, range.from],
|
|
362
|
+
GIT_TIMEOUTS.worktree,
|
|
363
|
+
);
|
|
364
|
+
const tree = merged.ok ? merged.stdout.split("\n")[0]?.trim() : null;
|
|
365
|
+
return Boolean(tree) && tree === git(cwd, ["rev-parse", "--verify", "-q", `${range.to}^{tree}`]);
|
|
366
|
+
}
|
|
367
|
+
|
|
341
368
|
// ---------------------------------------------------------------------------
|
|
342
369
|
// store root (D13)
|
|
343
370
|
|
|
@@ -1124,7 +1151,9 @@ export type Reverify = { scope: "focused"; since: string; files: string[] };
|
|
|
1124
1151
|
|
|
1125
1152
|
/** `carried (lane none since <head>)`: how a carried verdict reads in `ledger check` and `pr status`. */
|
|
1126
1153
|
export const carryLabel = (carry: CarryNote): string =>
|
|
1127
|
-
|
|
1154
|
+
carry.rule === "rebase"
|
|
1155
|
+
? "carried (rebase: patch unchanged)"
|
|
1156
|
+
: `carried (${carry.rule === "lane-none" ? "lane none" : "tests only"} since ${carry.since.slice(0, 12)})`;
|
|
1128
1157
|
|
|
1129
1158
|
export type VerdictCheck = {
|
|
1130
1159
|
branch: string;
|
|
@@ -1516,31 +1545,49 @@ export function checkVerdicts(
|
|
|
1516
1545
|
};
|
|
1517
1546
|
// The same patch-id and diff hash are not enough to carry a pass (#334):
|
|
1518
1547
|
// both drop hunk line numbers, so an edit moved within a file keeps them.
|
|
1519
|
-
// The branch's own files must be identical blobs too (sameOwnChange)
|
|
1520
|
-
//
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1548
|
+
// The branch's own files must be identical blobs too (sameOwnChange), or
|
|
1549
|
+
// the head must be exactly the judged head cleanly rebased onto the moved
|
|
1550
|
+
// base (cleanRebase): a base edit elsewhere in a file the branch touches.
|
|
1551
|
+
// A failure keeps the looser carry: it fails closed by applying more.
|
|
1552
|
+
// How a pass's own files match: identical blobs, a clean rebase, or neither.
|
|
1553
|
+
const exactCache = new Map<ReadRow, "same" | "rebase" | null>();
|
|
1554
|
+
const exactMatch = (row: ReadRow): "same" | "rebase" | null => {
|
|
1524
1555
|
let exact = exactCache.get(row);
|
|
1525
1556
|
if (exact === undefined) {
|
|
1526
1557
|
const toBase = row.base ?? fallbackBase;
|
|
1527
1558
|
const toBaseSha = toBase ? resolveCommit(cwd, toBase) : null;
|
|
1528
1559
|
const fromBase = row.baseSha ?? toBaseSha;
|
|
1529
|
-
|
|
1530
|
-
row.head &&
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1560
|
+
const range =
|
|
1561
|
+
row.head && head && toBaseSha && fromBase
|
|
1562
|
+
? { from: row.head, to: head, fromBase, toBase: toBaseSha }
|
|
1563
|
+
: null;
|
|
1564
|
+
exact = !range
|
|
1565
|
+
? null
|
|
1566
|
+
: sameOwnChange(cwd, range)
|
|
1567
|
+
? "same"
|
|
1568
|
+
: cleanRebase(cwd, range)
|
|
1569
|
+
? "rebase"
|
|
1570
|
+
: null;
|
|
1536
1571
|
exactCache.set(row, exact);
|
|
1537
1572
|
}
|
|
1538
1573
|
return exact;
|
|
1539
1574
|
};
|
|
1575
|
+
const exactCarry = (row: ReadRow): boolean =>
|
|
1576
|
+
FAILING.has(String(row.result)) || exactMatch(row) !== null;
|
|
1540
1577
|
const keyBasis = (row: ReadRow): Exclude<VerdictBasis, "none"> => {
|
|
1541
1578
|
const basis = verdictBasis(row, { head, ...keyFor(row.base ?? fallbackBase) });
|
|
1542
1579
|
return basis === "carried" && !exactCarry(row) ? "stale" : basis;
|
|
1543
1580
|
};
|
|
1581
|
+
/** A pass the exact keys carry only through a clean rebase (the base also edited a branch file). */
|
|
1582
|
+
const rebaseNote = (row: ReadRow): CarryNote | null =>
|
|
1583
|
+
row.head &&
|
|
1584
|
+
head &&
|
|
1585
|
+
row.head !== head &&
|
|
1586
|
+
!FAILING.has(String(row.result)) &&
|
|
1587
|
+
keyBasis(row) === "carried" &&
|
|
1588
|
+
exactMatch(row) === "rebase"
|
|
1589
|
+
? { rule: "rebase", since: row.head, files: [] }
|
|
1590
|
+
: null;
|
|
1544
1591
|
const restacked = (row: ReadRow): boolean =>
|
|
1545
1592
|
restackCarries(FAILING.has(String(row.result)) ? null : cwd, rows, branch, row.head, head);
|
|
1546
1593
|
/** The follow-up carry of a verdict the exact keys and restacks do not carry. */
|
|
@@ -1646,7 +1693,7 @@ export function checkVerdicts(
|
|
|
1646
1693
|
independent: independence.length === 0,
|
|
1647
1694
|
accepted: reasons.length === 0,
|
|
1648
1695
|
reasons,
|
|
1649
|
-
carry: basis === "carried" ? carryOf(row) : null,
|
|
1696
|
+
carry: basis === "carried" ? (rebaseNote(row) ?? carryOf(row)) : null,
|
|
1650
1697
|
verdict: row,
|
|
1651
1698
|
});
|
|
1652
1699
|
// The author's own failure never displaces an independent pass, but it
|