@agentskit/harness 0.10.0 → 0.11.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/CHANGELOG.md +12 -0
- package/README.md +1 -1
- package/capabilities/public-surface.json +105 -84
- package/dist/cli.js +353 -64
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +192 -56
- package/dist/index.js +325 -76
- package/dist/index.js.map +1 -1
- package/docs/ADR-0003-doc-bridge-context-binding.md +10 -3
- package/docs/ADR-0031-loop-observability.md +36 -0
- package/docs/LOOP.md +7 -0
- package/docs/MODULE-BOUNDARIES.md +1 -0
- package/loop.config.example.yaml +1 -1
- package/package.json +2 -2
- package/release/manifest.json +2 -2
- package/release/notes.md +6 -0
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createHash, randomUUID, createPrivateKey, createPublicKey, sign, verify } from 'crypto';
|
|
3
|
-
import { readFileSync, mkdirSync, existsSync, unlinkSync, openSync, writeFileSync, closeSync, readdirSync, writeSync, appendFileSync, mkdtempSync, renameSync, rmSync
|
|
4
|
-
import { Command } from 'commander';
|
|
3
|
+
import { readFileSync, mkdirSync, existsSync, unlinkSync, openSync, writeFileSync, closeSync, readdirSync, statSync, writeSync, appendFileSync, mkdtempSync, renameSync, rmSync } from 'fs';
|
|
5
4
|
import { resolve, dirname, relative, basename, join, extname, isAbsolute, delimiter, sep } from 'path';
|
|
5
|
+
import { Command } from 'commander';
|
|
6
6
|
import { execFile, spawn, execFileSync } from 'child_process';
|
|
7
7
|
import { promisify } from 'util';
|
|
8
8
|
import { tmpdir, totalmem, release, freemem, cpus, loadavg } from 'os';
|
|
@@ -921,11 +921,34 @@ var cleanTaskArtifacts = (configPath) => cleanConfiguredArtifacts(loadConfig(con
|
|
|
921
921
|
var index = (root, indexPath) => JSON.parse(readFileSync(resolve(root, indexPath), "utf8"));
|
|
922
922
|
var text = (entry) => [entry.id, entry.type, entry.title, entry.path, entry.description, entry.body, ...Array.isArray(entry.tags) ? entry.tags : []].filter((value) => typeof value === "string").join(" ").toLowerCase();
|
|
923
923
|
var sourceHash = (document) => typeof document.contentHash === "string" && document.contentHash.length > 0 ? document.contentHash : hashJson(document);
|
|
924
|
+
var tokenSeparator = /[^\p{L}\p{N}@/_-]+/gu;
|
|
925
|
+
var tokenize = (value) => value.toLowerCase().split(tokenSeparator).filter((token) => token.length >= 2);
|
|
926
|
+
var containsToken = (value, token) => {
|
|
927
|
+
if (/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u.test(token)) return value.includes(token);
|
|
928
|
+
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
929
|
+
return new RegExp(`(?:^|[^\\p{L}\\p{N}])${escaped}(?:s|es)?(?:[^\\p{L}\\p{N}]|$)`, "u").test(value);
|
|
930
|
+
};
|
|
931
|
+
var score = (value, query) => tokenize(query).reduce((total, token) => total + (containsToken(value, token) ? token.length : 0), 0);
|
|
924
932
|
var matches = (entry, query) => {
|
|
925
|
-
const needle = query.query.trim()
|
|
926
|
-
|
|
933
|
+
const needle = query.query.trim();
|
|
934
|
+
if (!needle) return 0;
|
|
927
935
|
const value = text(entry);
|
|
928
|
-
|
|
936
|
+
if (query.scope?.length && !query.scope.some((scope) => containsToken(value, scope.toLowerCase()))) return 0;
|
|
937
|
+
return score(value, needle);
|
|
938
|
+
};
|
|
939
|
+
var ownershipEntries = (document) => {
|
|
940
|
+
if (typeof document.lookup !== "object" || document.lookup === null || Array.isArray(document.lookup)) return [];
|
|
941
|
+
const ownership = document.lookup.ownership;
|
|
942
|
+
if (typeof ownership !== "object" || ownership === null || Array.isArray(ownership)) return [];
|
|
943
|
+
return Object.entries(ownership).flatMap(([id2, value]) => {
|
|
944
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return [];
|
|
945
|
+
const owner = value;
|
|
946
|
+
const path = typeof owner["agentDoc"] === "string" ? owner["agentDoc"] : owner["path"];
|
|
947
|
+
if (!path) return [];
|
|
948
|
+
const description = typeof owner["purpose"] === "string" ? owner["purpose"] : void 0;
|
|
949
|
+
const body2 = [owner["purpose"], owner["group"], owner["layer"], owner["agentDoc"], owner["humanDoc"]].filter((item) => typeof item === "string").join(" ");
|
|
950
|
+
return [{ id: typeof owner["id"] === "string" ? owner["id"] : id2, type: "ownership", path, ...description ? { description } : {}, ...body2 ? { body: body2 } : {} }];
|
|
951
|
+
});
|
|
929
952
|
};
|
|
930
953
|
var inspectDocBridgeIndex = (root, indexPath = ".doc-bridge/index.json", now4 = Date.now()) => {
|
|
931
954
|
const path = resolve(root, indexPath);
|
|
@@ -940,15 +963,30 @@ var inspectDocBridgeIndex = (root, indexPath = ".doc-bridge/index.json", now4 =
|
|
|
940
963
|
return { present: true, path, contentHash: null, mtimeMs: null, ageHours: null, error: error instanceof Error ? error.message : String(error) };
|
|
941
964
|
}
|
|
942
965
|
};
|
|
943
|
-
var createDocBridgeContextProvider = ({ root, indexPath = ".doc-bridge/index.json" }) => ({
|
|
966
|
+
var createDocBridgeContextProvider = ({ root, indexPath = ".doc-bridge/index.json", maxAgeHours, now: now4 = Date.now }) => ({
|
|
944
967
|
id: "doc-bridge",
|
|
945
|
-
version: "1.
|
|
968
|
+
version: "1.1.0",
|
|
946
969
|
resolve: async (query) => {
|
|
947
970
|
const started = Date.now();
|
|
971
|
+
const ageBudget = maxAgeHours ?? 0;
|
|
972
|
+
const inspection = ageBudget > 0 ? inspectDocBridgeIndex(root, indexPath, now4()) : null;
|
|
973
|
+
if (inspection?.error) throw new Error(`Doc Bridge index is unreadable: ${inspection.error}`);
|
|
974
|
+
if (inspection?.ageHours !== null && inspection?.ageHours !== void 0 && inspection.ageHours > ageBudget) {
|
|
975
|
+
throw new Error(`Doc Bridge index is ${inspection.ageHours.toFixed(1)}h old; refresh it before resolving context.`);
|
|
976
|
+
}
|
|
948
977
|
const document = index(root, indexPath);
|
|
949
978
|
const contentHash = sourceHash(document);
|
|
950
|
-
const
|
|
951
|
-
const
|
|
979
|
+
const knowledge = Array.isArray(document.knowledge) ? document.knowledge.filter((value) => typeof value === "object" && value !== null && !Array.isArray(value)) : [];
|
|
980
|
+
const ranked = [...knowledge, ...ownershipEntries(document)].map((entry) => ({ entry, score: matches(entry, query) })).filter(({ score: entryScore }) => entryScore > 0);
|
|
981
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
982
|
+
for (const candidate of ranked) {
|
|
983
|
+
const path = typeof candidate.entry.path === "string" ? candidate.entry.path : String(candidate.entry.id ?? "");
|
|
984
|
+
const current = byPath.get(path);
|
|
985
|
+
if (!current || candidate.score > current.score || candidate.score === current.score && candidate.entry.type === "ownership" && current.entry.type !== "ownership") byPath.set(path, candidate);
|
|
986
|
+
}
|
|
987
|
+
const entries = [...byPath.values()].sort((left, right) => right.score - left.score || String(left.entry.id ?? "").localeCompare(String(right.entry.id ?? ""))).slice(0, 8);
|
|
988
|
+
const maxScore = entries[0]?.score ?? 1;
|
|
989
|
+
const references = entries.flatMap(({ entry, score: entryScore }) => typeof entry.id === "string" && typeof entry.path === "string" ? [{ id: entry.id, uri: `doc-bridge://${entry.path}`, ...typeof entry.title === "string" ? { title: entry.title } : {}, contentHash: typeof entry.contentHash === "string" ? entry.contentHash : contentHash, relevance: entryScore / maxScore }] : []);
|
|
952
990
|
const telemetry = { status: "measured", durationMs: Date.now() - started, contextReferences: references.length, contextCostTokens: Math.max(1, Math.ceil(JSON.stringify(references).length / 4)) };
|
|
953
991
|
return { providerId: "doc-bridge", query, references, sourceHash: contentHash, snapshotHash: hashContextSnapshot({ providerId: "doc-bridge", query, references, sourceHash: contentHash }), resolvedAt: (/* @__PURE__ */ new Date()).toISOString(), assurance: "contract-tested", telemetry };
|
|
954
992
|
}
|
|
@@ -1543,6 +1581,14 @@ var createKvMemoryAdapter = (store, options2 = {}) => {
|
|
|
1543
1581
|
const scopeMatch = record3.scope === "global" || (record3.scope === "issue" ? Boolean(issueId && record3.source.includes(issueId)) : Boolean(project && record3.source.includes(project)));
|
|
1544
1582
|
return scopeMatch && (!query || `${record3.summary} ${record3.source}`.toLowerCase().includes(query));
|
|
1545
1583
|
};
|
|
1584
|
+
let allRecordsCache = null;
|
|
1585
|
+
const allRecords = async () => {
|
|
1586
|
+
if (allRecordsCache) return allRecordsCache;
|
|
1587
|
+
const ids = await store.get(indexKey);
|
|
1588
|
+
const records = Array.isArray(ids) ? await Promise.all(ids.filter((id2) => typeof id2 === "string").map((id2) => store.get(`agentskit-harness:memory:${id2}`))) : [];
|
|
1589
|
+
allRecordsCache = records.filter((record3) => Boolean(record3 && typeof record3 === "object" && record3.approved === true));
|
|
1590
|
+
return allRecordsCache;
|
|
1591
|
+
};
|
|
1546
1592
|
return {
|
|
1547
1593
|
id: options2.id ?? "agentskit-kv",
|
|
1548
1594
|
version: options2.version ?? "1",
|
|
@@ -1554,13 +1600,13 @@ var createKvMemoryAdapter = (store, options2 = {}) => {
|
|
|
1554
1600
|
const index2 = Array.isArray(ids) ? ids.filter((id2) => typeof id2 === "string") : [];
|
|
1555
1601
|
if (!index2.includes(valid.id)) await store.set(indexKey, [...index2, valid.id].sort());
|
|
1556
1602
|
await store.set(`agentskit-harness:memory:${valid.id}`, valid);
|
|
1603
|
+
allRecordsCache = null;
|
|
1557
1604
|
writes += 1;
|
|
1558
1605
|
},
|
|
1559
1606
|
async recall({ query, issueId, project, sourceRevision }) {
|
|
1560
1607
|
reads += 1;
|
|
1561
|
-
const
|
|
1562
|
-
const
|
|
1563
|
-
const hits = records.filter((record3) => Boolean(record3 && typeof record3 === "object" && record3.approved === true)).filter((record3) => matches2(record3, query.trim().toLowerCase(), issueId, project)).map((record3) => ({ record: record3, relevant: true, stale: sourceRevision !== void 0 && record3.sourceRevision !== sourceRevision }));
|
|
1608
|
+
const records = await allRecords();
|
|
1609
|
+
const hits = records.filter((record3) => matches2(record3, query.trim().toLowerCase(), issueId, project)).map((record3) => ({ record: record3, relevant: true, stale: sourceRevision !== void 0 && record3.sourceRevision !== sourceRevision }));
|
|
1564
1610
|
relevantHits += hits.length;
|
|
1565
1611
|
staleHits += hits.filter((hit) => hit.stale).length;
|
|
1566
1612
|
return hits;
|
|
@@ -2514,10 +2560,16 @@ var orcaTerminalCreate = async (runner, input, options2 = {}) => {
|
|
|
2514
2560
|
};
|
|
2515
2561
|
var parseOrcaSendReceipt = (result) => {
|
|
2516
2562
|
const record3 = isRecord7(result) ? result : {};
|
|
2517
|
-
const
|
|
2518
|
-
const
|
|
2519
|
-
const
|
|
2520
|
-
|
|
2563
|
+
const send = isRecord7(record3["send"]) ? record3["send"] : null;
|
|
2564
|
+
const prompt = send && isRecord7(send["prompt"]) ? send["prompt"] : null;
|
|
2565
|
+
const receipt = isRecord7(record3["receipt"]) ? record3["receipt"] : send ?? record3;
|
|
2566
|
+
const rawStages = Array.isArray(receipt["stages"]) ? receipt["stages"] : prompt && Array.isArray(prompt["stages"]) ? prompt["stages"] : [];
|
|
2567
|
+
const stages = rawStages.map((stage) => isRecord7(stage) ? str(stage["stage"], str(stage["name"])) : str(stage)).filter(Boolean);
|
|
2568
|
+
const inputAccepted = stages.some((stage) => ["input_accepted", "input_queued", "prompt_accepted", "queued"].includes(stage.toLowerCase()));
|
|
2569
|
+
const acceptedValue = receipt["accepted"] ?? send?.["accepted"];
|
|
2570
|
+
const accepted = inputAccepted || acceptedValue === true || acceptedValue !== false && (result === null || result === void 0 || Object.keys(record3).length === 0);
|
|
2571
|
+
const warnings = Array.isArray(record3["warnings"]) ? record3["warnings"] : send && Array.isArray(send["warnings"]) ? send["warnings"] : [];
|
|
2572
|
+
return { accepted, requestId: str(receipt["requestId"], str(prompt?.["requestId"], str(record3["requestId"]))) || null, stages, warnings: warnings.map((warning) => isRecord7(warning) ? str(warning["message"], JSON.stringify(warning)) : str(warning)) };
|
|
2521
2573
|
};
|
|
2522
2574
|
var orcaTerminalSend = async (runner, input, options2 = {}) => parseOrcaSendReceipt(await orcaJson(runner, ["terminal", "send", "--terminal", input.terminal, "--text", input.text, ...input.enter === false ? [] : ["--enter"], ...input.waitSubmitSeconds ? ["--wait-submit", String(input.waitSubmitSeconds)] : []], { ...options2, timeoutMs: options2.timeoutMs ?? (input.waitSubmitSeconds ?? 0) * 1e3 + 3e4 }));
|
|
2523
2575
|
var orcaTerminalWait = async (runner, input, options2 = {}) => {
|
|
@@ -2770,6 +2822,8 @@ var LoopConfigSchema = z.object({
|
|
|
2770
2822
|
}).prefault({}),
|
|
2771
2823
|
catalog: z.object({
|
|
2772
2824
|
sources: z.array(z.enum(["cli", "artificial-analysis", "builtin"])).default(["cli", "builtin"]),
|
|
2825
|
+
/** How long a provider's CLI-discovered model list (e.g. `grok models`) is trusted before spawning the CLI again — it rarely changes between releases. */
|
|
2826
|
+
cliCacheHours: z.number().positive().default(6),
|
|
2773
2827
|
artificialAnalysis: z.object({
|
|
2774
2828
|
enabled: z.boolean().default(false),
|
|
2775
2829
|
apiKeyEnv: nonEmpty2.default("ARTIFICIAL_ANALYSIS_API_KEY"),
|
|
@@ -3292,7 +3346,12 @@ var selectModel = (config, role, availability, extraCandidates = []) => {
|
|
|
3292
3346
|
var routeAllRoles = (config, availability, extrasByRole = {}) => Object.fromEntries(MODEL_ROLES.map((role) => [role, selectModel(config, role, availability, extrasByRole[role] ?? [])]));
|
|
3293
3347
|
var rankModels = (config, role, availability, extraCandidates = []) => {
|
|
3294
3348
|
const byId = new Map(availability.map((item) => [item.id, item]));
|
|
3295
|
-
const { ranked } = availableFromTiers(config, role, availability);
|
|
3349
|
+
const { ranked, skipped } = availableFromTiers(config, role, availability);
|
|
3350
|
+
if (config.models.routing.pin[role]) {
|
|
3351
|
+
const pinned = applyPin(config, role, availability, skipped);
|
|
3352
|
+
if (pinned) return [pinned, ...ranked.filter((item) => !(item.provider === pinned.provider && item.model === pinned.model))];
|
|
3353
|
+
if (config.models.routing.pinStrict) return [];
|
|
3354
|
+
}
|
|
3296
3355
|
const extras = [];
|
|
3297
3356
|
let extraIndex = 1e4;
|
|
3298
3357
|
for (const ref of extraCandidates) {
|
|
@@ -3413,6 +3472,32 @@ ${outcome.stderr}`);
|
|
|
3413
3472
|
}
|
|
3414
3473
|
return [];
|
|
3415
3474
|
};
|
|
3475
|
+
var cliModelsCachePath = (stateDir, provider) => join(stateDir, "catalog", `cli-${provider}.json`);
|
|
3476
|
+
var readCliModelsCache = (stateDir, provider) => {
|
|
3477
|
+
const path = cliModelsCachePath(stateDir, provider);
|
|
3478
|
+
if (!existsSync(path)) return null;
|
|
3479
|
+
try {
|
|
3480
|
+
const raw = readJson2(path);
|
|
3481
|
+
return typeof raw.fetchedAt === "string" && Array.isArray(raw.ids) ? { fetchedAt: raw.fetchedAt, ids: raw.ids } : null;
|
|
3482
|
+
} catch {
|
|
3483
|
+
return null;
|
|
3484
|
+
}
|
|
3485
|
+
};
|
|
3486
|
+
var writeCliModelsCache = (stateDir, provider, ids, now4 = /* @__PURE__ */ new Date()) => {
|
|
3487
|
+
const path = cliModelsCachePath(stateDir, provider);
|
|
3488
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
3489
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
3490
|
+
writeFileSync(tmp, `${JSON.stringify({ fetchedAt: now4.toISOString(), ids }, null, 2)}
|
|
3491
|
+
`, "utf8");
|
|
3492
|
+
renameSync(tmp, path);
|
|
3493
|
+
};
|
|
3494
|
+
var listCliModelsCached = async (provider, bin, runner, stateDir, cacheHours, now4 = () => /* @__PURE__ */ new Date()) => {
|
|
3495
|
+
const cached = readCliModelsCache(stateDir, provider);
|
|
3496
|
+
if (cached && now4().getTime() - Date.parse(cached.fetchedAt) <= cacheHours * 36e5) return cached.ids;
|
|
3497
|
+
const ids = await listCliModels(provider, bin, runner);
|
|
3498
|
+
if (ids.length) writeCliModelsCache(stateDir, provider, ids, now4());
|
|
3499
|
+
return ids.length ? ids : cached?.ids ?? [];
|
|
3500
|
+
};
|
|
3416
3501
|
var parseArtificialAnalysisPayload = (payload) => {
|
|
3417
3502
|
const root = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
|
|
3418
3503
|
const data = Array.isArray(root["data"]) ? root["data"] : Array.isArray(payload) ? payload : [];
|
|
@@ -3497,7 +3582,7 @@ var resolveCatalogCandidates = async (input) => {
|
|
|
3497
3582
|
const settings = config.models.providers[provider];
|
|
3498
3583
|
if (settings) {
|
|
3499
3584
|
try {
|
|
3500
|
-
const ids = await listCliModels(provider, settings.bin, input.runner);
|
|
3585
|
+
const ids = input.stateDir ? await listCliModelsCached(provider, settings.bin, input.runner, input.stateDir, config.models.catalog.cliCacheHours, input.now) : await listCliModels(provider, settings.bin, input.runner);
|
|
3501
3586
|
for (const id2 of ids) {
|
|
3502
3587
|
const resolved = resolveAlias(provider, id2, aliases);
|
|
3503
3588
|
const existing = builtin[provider]?.models.find((model) => model.id === resolved);
|
|
@@ -3529,9 +3614,9 @@ var resolveCatalogCandidates = async (input) => {
|
|
|
3529
3614
|
const matches2 = models.models.filter((model) => model.creatorSlug === creator || model.creatorSlug.includes(creator));
|
|
3530
3615
|
for (const model of matches2) {
|
|
3531
3616
|
const id2 = resolveAlias(provider, model.slug, aliases);
|
|
3532
|
-
const
|
|
3533
|
-
const quality =
|
|
3534
|
-
push(provider, { id: id2, quality, codingScore:
|
|
3617
|
+
const score2 = model.codingIndex ?? model.intelligenceIndex ?? 50;
|
|
3618
|
+
const quality = score2 >= 80 ? "frontier" : score2 >= 60 ? "balanced" : "fast";
|
|
3619
|
+
push(provider, { id: id2, quality, codingScore: score2, source: "artificial-analysis", creator });
|
|
3535
3620
|
}
|
|
3536
3621
|
}
|
|
3537
3622
|
}
|
|
@@ -3583,6 +3668,16 @@ var markProviderExhausted = (stateDir, provider, options2) => {
|
|
|
3583
3668
|
return entry;
|
|
3584
3669
|
};
|
|
3585
3670
|
var rotationStatePath = (stateDir) => join(stateDir, "queue-owner.json");
|
|
3671
|
+
var countRotationBlockingLeases = (loaded, leases) => leases.filter((lease) => {
|
|
3672
|
+
const path = join(loaded.stateDir, "issues", lease.issue, "delivery.json");
|
|
3673
|
+
if (!existsSync(path)) return true;
|
|
3674
|
+
try {
|
|
3675
|
+
const delivery2 = JSON.parse(readFileSync(path, "utf8"));
|
|
3676
|
+
return delivery2.prNumber == null && !delivery2.heldFor && !delivery2.finalOutcome;
|
|
3677
|
+
} catch {
|
|
3678
|
+
return true;
|
|
3679
|
+
}
|
|
3680
|
+
}).length;
|
|
3586
3681
|
var queueOwner = (loaded) => {
|
|
3587
3682
|
const { rotation } = loaded.config.linear;
|
|
3588
3683
|
if (!rotation.enabled || !rotation.owners.length) return loaded.config.linear.person;
|
|
@@ -4077,10 +4172,10 @@ var planMemoryContext = async (input) => {
|
|
|
4077
4172
|
hits = [];
|
|
4078
4173
|
}
|
|
4079
4174
|
const selected = selectMemoryForPrompt(hits, memory);
|
|
4080
|
-
const beforeChars = input.references.reduce((
|
|
4175
|
+
const beforeChars = input.references.reduce((sum2, ref) => sum2 + JSON.stringify(ref).length, 0) + issueBudgetDefault;
|
|
4081
4176
|
const preferred = memory.preferOverDocBridge ? preferMemoryOverDocBridge(input.references, selected.hits, memory.minDocBridgeWhenMemory) : { references: input.references};
|
|
4082
4177
|
const issueCharBudget = selected.hits.length && memory.shrinkIssueCharsWhenMemory ? Math.min(issueBudgetDefault, memory.issueCharsWithMemory) : issueBudgetDefault;
|
|
4083
|
-
const afterChars = preferred.references.reduce((
|
|
4178
|
+
const afterChars = preferred.references.reduce((sum2, ref) => sum2 + JSON.stringify(ref).length, 0) + issueCharBudget + selected.approxChars;
|
|
4084
4179
|
return {
|
|
4085
4180
|
hits: selected.hits,
|
|
4086
4181
|
references: preferred.references,
|
|
@@ -4535,7 +4630,6 @@ var resumeIssue = (stateDir, issue) => {
|
|
|
4535
4630
|
writeIssueFailures(stateDir, next);
|
|
4536
4631
|
return next;
|
|
4537
4632
|
};
|
|
4538
|
-
var isIssuePaused = (stateDir, issue) => readIssueFailures(stateDir, issue).pausedAt !== null;
|
|
4539
4633
|
var listPausedIssues = (stateDir) => {
|
|
4540
4634
|
const dir = join(stateDir, "issues");
|
|
4541
4635
|
if (!existsSync(dir)) return [];
|
|
@@ -4637,9 +4731,14 @@ var writeDispatchRecord = (stateDir, record3) => {
|
|
|
4637
4731
|
writeJson2(path, record3);
|
|
4638
4732
|
return path;
|
|
4639
4733
|
};
|
|
4640
|
-
var
|
|
4734
|
+
var EVENTS_ROTATE_AT_BYTES = 10 * 1024 * 1024;
|
|
4735
|
+
var appendLoopEvent = (stateDir, event2, bus, now4 = () => /* @__PURE__ */ new Date()) => {
|
|
4641
4736
|
const path = join(stateDir, "events.ndjson");
|
|
4642
4737
|
mkdirSync(dirname(path), { recursive: true });
|
|
4738
|
+
try {
|
|
4739
|
+
if (statSync(path).size > EVENTS_ROTATE_AT_BYTES) renameSync(path, join(stateDir, `events-archive-${now4().getTime()}.ndjson`));
|
|
4740
|
+
} catch {
|
|
4741
|
+
}
|
|
4643
4742
|
appendFileSync(path, `${JSON.stringify(event2)}
|
|
4644
4743
|
`, "utf8");
|
|
4645
4744
|
if (bus && typeof event2["type"] === "string") bus.emit(event2);
|
|
@@ -4671,7 +4770,7 @@ var gatherLoopState = async (input) => {
|
|
|
4671
4770
|
const leases = input.ledger.active();
|
|
4672
4771
|
const busy = busyIssues(queue, leases, worktrees, person);
|
|
4673
4772
|
const candidates = queue.filter((issue) => !busy.has(issue.identifier) && (!input.onlyIssue || issue.identifier === input.onlyIssue));
|
|
4674
|
-
return { person, providers, routing, worktrees, slots, queue, leases, busy, candidates };
|
|
4773
|
+
return { person, providers, routing, worktrees, slots, queue, leases, busy, candidates, extrasByRole };
|
|
4675
4774
|
};
|
|
4676
4775
|
var precheckTick = async (input) => {
|
|
4677
4776
|
const loaded = input.loaded ?? loadLoopConfig(input.configPath);
|
|
@@ -4712,16 +4811,7 @@ var runTick = async (input) => {
|
|
|
4712
4811
|
}
|
|
4713
4812
|
const state = await gatherLoopState({ loaded, runner: input.runner, ledger, env: input.env, platform: input.platform, now: now4, onlyIssue: input.onlyIssue, machine: input.machine });
|
|
4714
4813
|
const orchestrator = state.routing["orchestrator"] ?? { role: "orchestrator", selected: null, skipped: [] };
|
|
4715
|
-
const
|
|
4716
|
-
config,
|
|
4717
|
-
role: "orchestrator",
|
|
4718
|
-
availableProviderIds: state.providers.filter((provider) => provider.available).map((provider) => provider.id),
|
|
4719
|
-
runner: input.runner,
|
|
4720
|
-
stateDir: loaded.stateDir,
|
|
4721
|
-
env: input.env,
|
|
4722
|
-
now: now4
|
|
4723
|
-
}) : [];
|
|
4724
|
-
const orchestratorCandidates = rankModels(config, "orchestrator", state.providers, orchestratorExtras);
|
|
4814
|
+
const orchestratorCandidates = rankModels(config, "orchestrator", state.providers, state.extrasByRole["orchestrator"] ?? []);
|
|
4725
4815
|
const onProviderFailure = (failure) => {
|
|
4726
4816
|
if (dryRun) return;
|
|
4727
4817
|
const resetsAt = extractResetsAt(failure.detail, now4());
|
|
@@ -4741,7 +4831,7 @@ var runTick = async (input) => {
|
|
|
4741
4831
|
return { ...base, status: "idle", results, notes };
|
|
4742
4832
|
}
|
|
4743
4833
|
if (!state.candidates.length) {
|
|
4744
|
-
const rotation = dryRun ? { owner: state.person, advanced: false } : advanceQueueOwner(loaded, { queueEmpty: state.queue.length === 0, activeLeases: state.leases
|
|
4834
|
+
const rotation = dryRun ? { owner: state.person, advanced: false } : advanceQueueOwner(loaded, { queueEmpty: state.queue.length === 0, activeLeases: countRotationBlockingLeases(loaded, state.leases), now: now4() });
|
|
4745
4835
|
if (rotation.advanced) notes.push(`queue drained for ${state.person}; switched to ${rotation.owner}`);
|
|
4746
4836
|
else notes.push("queue has no dispatchable candidate");
|
|
4747
4837
|
return { ...base, status: "idle", results, notes };
|
|
@@ -4774,17 +4864,32 @@ The loop will not retry this issue until you remove the \`${config.resilience.pa
|
|
|
4774
4864
|
appendLoopEvent(loaded.stateDir, { at: now4().toISOString(), type: "issue.paused", issue, kind, consecutive: failureState.consecutive, reason }, bus);
|
|
4775
4865
|
await bus.runHook("onPause", { issue, kind, consecutive: failureState.consecutive, reason });
|
|
4776
4866
|
};
|
|
4867
|
+
let pinnedSkillsOnce;
|
|
4868
|
+
const getPinnedSkills = () => {
|
|
4869
|
+
if (pinnedSkillsOnce === void 0) {
|
|
4870
|
+
try {
|
|
4871
|
+
pinnedSkillsOnce = loadPinnedSkills(loaded.root, config.brief.skills, config.brief.maxSkillChars);
|
|
4872
|
+
} catch (error) {
|
|
4873
|
+
pinnedSkillsOnce = { error };
|
|
4874
|
+
throw error;
|
|
4875
|
+
}
|
|
4876
|
+
}
|
|
4877
|
+
if ("error" in pinnedSkillsOnce) throw pinnedSkillsOnce.error;
|
|
4878
|
+
return pinnedSkillsOnce;
|
|
4879
|
+
};
|
|
4777
4880
|
let dispatched = 0;
|
|
4778
4881
|
for (const candidate of state.candidates) {
|
|
4779
4882
|
if (dispatched >= budget) break;
|
|
4780
4883
|
const setupBudgetMs = config.project.setup.command ? Number.isFinite(timeBudgetMs) ? Math.min(config.project.setup.timeoutSec * 1e3, Math.max(0, timeBudgetMs - config.contract.timeoutMs - 125e3)) : config.project.setup.timeoutSec * 1e3 : 0;
|
|
4781
|
-
|
|
4884
|
+
const cachedContract = readStoredContract(loaded.stateDir, candidate.identifier);
|
|
4885
|
+
if (remainingMs() < config.contract.timeoutMs + setupBudgetMs + 12e4 && !cachedContract) {
|
|
4782
4886
|
notes.push(`time budget: ${candidate.identifier} left for the next tick (${Math.round(remainingMs() / 1e3)}s remaining)`);
|
|
4783
4887
|
continue;
|
|
4784
4888
|
}
|
|
4785
|
-
|
|
4889
|
+
const failureState = readIssueFailures(loaded.stateDir, candidate.identifier);
|
|
4890
|
+
if (failureState.pausedAt !== null) {
|
|
4786
4891
|
if (candidate.labels.includes(config.resilience.pausedLabel)) {
|
|
4787
|
-
results.push({ issue: candidate.identifier, outcome: "skipped", reason: `paused after ${
|
|
4892
|
+
results.push({ issue: candidate.identifier, outcome: "skipped", reason: `paused after ${failureState.consecutive} consecutive failures; remove the "${config.resilience.pausedLabel}" label or run "ak-harness loop resume ${candidate.identifier}" to retry` });
|
|
4788
4893
|
continue;
|
|
4789
4894
|
}
|
|
4790
4895
|
if (!dryRun) clearIssueFailures(loaded.stateDir, candidate.identifier);
|
|
@@ -4797,8 +4902,8 @@ The loop will not retry this issue until you remove the \`${config.resilience.pa
|
|
|
4797
4902
|
results.push({ issue: candidate.identifier, outcome: "failed", reason: `issue fetch failed: ${message2(error)}` });
|
|
4798
4903
|
continue;
|
|
4799
4904
|
}
|
|
4800
|
-
let stored =
|
|
4801
|
-
const
|
|
4905
|
+
let stored = cachedContract;
|
|
4906
|
+
const memoryPlan = memory ? await planMemoryContext({
|
|
4802
4907
|
adapter: memory,
|
|
4803
4908
|
config,
|
|
4804
4909
|
issueId: detail.identifier,
|
|
@@ -4806,7 +4911,7 @@ The loop will not retry this issue until you remove the \`${config.resilience.pa
|
|
|
4806
4911
|
project: config.project.name,
|
|
4807
4912
|
references: []
|
|
4808
4913
|
}) : null;
|
|
4809
|
-
if (stored && !contractIsFresh(stored, detail, config.contract.reuseHours, now4(),
|
|
4914
|
+
if (stored && !contractIsFresh(stored, detail, config.contract.reuseHours, now4(), memoryPlan?.memoryDigest)) stored = null;
|
|
4810
4915
|
if (!stored) {
|
|
4811
4916
|
if (input.skipContractGeneration) {
|
|
4812
4917
|
results.push({ issue: detail.identifier, outcome: "skipped", reason: "no cached contract; generation skipped" });
|
|
@@ -4906,16 +5011,9 @@ The loop will not retry this issue until you remove the \`${config.resilience.pa
|
|
|
4906
5011
|
}
|
|
4907
5012
|
if (setupFailed) notes.push(`${detail.identifier}: setup command failed but project.setup.required is false \u2014 continuing`);
|
|
4908
5013
|
}
|
|
4909
|
-
const briefMemory =
|
|
4910
|
-
adapter: memory,
|
|
4911
|
-
config,
|
|
4912
|
-
issueId: detail.identifier,
|
|
4913
|
-
issueTitle: detail.title,
|
|
4914
|
-
project: config.project.name,
|
|
4915
|
-
references: []
|
|
4916
|
-
}) : { memoryBlock: "", issueCharBudget: config.contract.maxIssueChars, hits: [] };
|
|
5014
|
+
const briefMemory = memoryPlan ?? { memoryBlock: "", issueCharBudget: config.contract.maxIssueChars, hits: [] };
|
|
4917
5015
|
const guidanceRefs = config.contract.maxBriefReferences > 0 && config.contract.briefScopes.length ? await resolveDocContext(loaded.root, `${detail.identifier} ${detail.title}`, config.contract.maxBriefReferences, config.contract.briefScopes) : [];
|
|
4918
|
-
const pinnedSkills =
|
|
5016
|
+
const pinnedSkills = getPinnedSkills();
|
|
4919
5017
|
const brief = renderWorkerBrief({
|
|
4920
5018
|
issue: detail,
|
|
4921
5019
|
contract: stored,
|
|
@@ -5245,7 +5343,7 @@ var providerUnavailable = (ctx, providerId) => {
|
|
|
5245
5343
|
return !match || !match.available;
|
|
5246
5344
|
};
|
|
5247
5345
|
var pickHandoffBuilder = (ctx, record3) => {
|
|
5248
|
-
const ranked = rankModels(ctx.config, "builder", ctx.providers);
|
|
5346
|
+
const ranked = rankModels(ctx.config, "builder", ctx.providers, ctx.builderExtras);
|
|
5249
5347
|
const different = ranked.find((candidate) => candidate.provider !== record3.provider || candidate.model !== record3.model);
|
|
5250
5348
|
return different ?? null;
|
|
5251
5349
|
};
|
|
@@ -5686,7 +5784,8 @@ var runDeliver = async (input) => {
|
|
|
5686
5784
|
const availableIds = providers.filter((provider) => provider.available).map((provider) => provider.id);
|
|
5687
5785
|
const catalogExtras = async (role) => config.models.routing.mode === "catalog" ? resolveCatalogCandidates({ config, role, availableProviderIds: availableIds, runner: input.runner, stateDir: loaded.stateDir, env: input.env, now: now4 }) : Promise.resolve([]);
|
|
5688
5786
|
const reviewer = rankModels(config, "reviewer", providers, await catalogExtras("reviewer"))[0] ?? null;
|
|
5689
|
-
const
|
|
5787
|
+
const builderExtras = await catalogExtras("builder");
|
|
5788
|
+
const builder = rankModels(config, "builder", providers, builderExtras)[0] ?? null;
|
|
5690
5789
|
let env = input.env ?? process.env;
|
|
5691
5790
|
if (!env["GITHUB_TOKEN"] && !env["GH_TOKEN"]) {
|
|
5692
5791
|
try {
|
|
@@ -5702,13 +5801,14 @@ var runDeliver = async (input) => {
|
|
|
5702
5801
|
const { errors } = await loadLoopPlugins(loaded.root, config.plugins.modules, bus);
|
|
5703
5802
|
for (const failure of errors) notes.push(`plugin ${failure.path} failed to load: ${failure.error}`);
|
|
5704
5803
|
}
|
|
5705
|
-
const ctx = { loaded, config, runner: input.runner, now: now4, dryRun, reviewer, builder, providers, env, ...input.assumeIdle === void 0 ? {} : { assumeIdle: input.assumeIdle }, notes, reviewDeadlineMs, bus };
|
|
5804
|
+
const ctx = { loaded, config, runner: input.runner, now: now4, dryRun, reviewer, builder, providers, env, ...input.assumeIdle === void 0 ? {} : { assumeIdle: input.assumeIdle }, notes, reviewDeadlineMs, bus, builderExtras };
|
|
5706
5805
|
const ledger = createDispatchLedger(loaded.stateDir);
|
|
5707
5806
|
const leases = new Map(ledger.active().map((lease) => [lease.issue, lease]));
|
|
5708
5807
|
const results = [];
|
|
5709
5808
|
for (const record3 of listDispatched(loaded.stateDir)) {
|
|
5710
5809
|
if (input.onlyIssue && record3.issue !== input.onlyIssue) continue;
|
|
5711
5810
|
let state = readDeliveryState(loaded.stateDir, record3.issue);
|
|
5811
|
+
if (state.finishedAt && state.finalOutcome === "merged") continue;
|
|
5712
5812
|
const lease = leases.get(record3.issue);
|
|
5713
5813
|
if (!state.finishedAt) {
|
|
5714
5814
|
const ageMinutes = minutesBetween(now4(), record3.dispatchedAt);
|
|
@@ -5747,7 +5847,6 @@ var runDeliver = async (input) => {
|
|
|
5747
5847
|
results.push(await handlePullRequest(ctx, record3, lease, state, pr));
|
|
5748
5848
|
continue;
|
|
5749
5849
|
}
|
|
5750
|
-
if (state.finishedAt && state.finalOutcome === "merged") continue;
|
|
5751
5850
|
const recordedMerge = readMergedEvent(loaded.stateDir, record3.issue);
|
|
5752
5851
|
if (recordedMerge) {
|
|
5753
5852
|
try {
|
|
@@ -6308,10 +6407,19 @@ ${step && total ? `${step}/${total} ` : ""}${title}`),
|
|
|
6308
6407
|
] }))
|
|
6309
6408
|
};
|
|
6310
6409
|
};
|
|
6410
|
+
var newestIssueMtimeMs = (stateDir, issue) => {
|
|
6411
|
+
const mtimes = [dispatchRecordPath(stateDir, issue), deliveryStatePath(stateDir, issue), contractPath(stateDir, issue)].map((path) => {
|
|
6412
|
+
try {
|
|
6413
|
+
return statSync(path).mtimeMs;
|
|
6414
|
+
} catch {
|
|
6415
|
+
return null;
|
|
6416
|
+
}
|
|
6417
|
+
}).filter((value) => value !== null);
|
|
6418
|
+
return mtimes.length ? Math.max(...mtimes) : null;
|
|
6419
|
+
};
|
|
6311
6420
|
var HARNESS_REPO_URL = "https://github.com/AgentsKit-io/harness";
|
|
6312
6421
|
var isRecord13 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6313
|
-
var
|
|
6314
|
-
const path = join(stateDir, "events.ndjson");
|
|
6422
|
+
var parseEventsFile = (path) => {
|
|
6315
6423
|
if (!existsSync(path)) return [];
|
|
6316
6424
|
return readFileSync(path, "utf8").split(/\r?\n/).filter(Boolean).flatMap((line2) => {
|
|
6317
6425
|
try {
|
|
@@ -6322,6 +6430,11 @@ var readLoopEvents = (stateDir) => {
|
|
|
6322
6430
|
}
|
|
6323
6431
|
});
|
|
6324
6432
|
};
|
|
6433
|
+
var eventsArchivePattern = /^events-archive-(\d+)\.ndjson$/;
|
|
6434
|
+
var readLoopEvents = (stateDir, sinceMs) => {
|
|
6435
|
+
const archives = existsSync(stateDir) ? readdirSync(stateDir).map((name2) => name2.match(eventsArchivePattern)).filter((match) => match !== null).map((match) => ({ path: join(stateDir, match[0]), rotatedAtMs: Number(match[1]) })).filter((archive) => sinceMs === void 0 || archive.rotatedAtMs >= sinceMs).sort((a, b) => a.rotatedAtMs - b.rotatedAtMs) : [];
|
|
6436
|
+
return [...archives.flatMap((archive) => parseEventsFile(archive.path)), ...parseEventsFile(join(stateDir, "events.ndjson"))];
|
|
6437
|
+
};
|
|
6325
6438
|
var parseSince = (value, now4) => {
|
|
6326
6439
|
if (!value) return new Date(now4.getTime() - 7 * 864e5);
|
|
6327
6440
|
const match = value.match(/^(\d+)([dhm])$/);
|
|
@@ -6368,10 +6481,11 @@ var buildSuggestions = (input) => {
|
|
|
6368
6481
|
var buildRetroReport = async (input) => {
|
|
6369
6482
|
const loaded = input.loaded ?? loadLoopConfig(input.configPath);
|
|
6370
6483
|
const { config } = loaded;
|
|
6484
|
+
const person = queueOwner(loaded);
|
|
6371
6485
|
const now4 = (input.now ?? (() => /* @__PURE__ */ new Date()))();
|
|
6372
6486
|
const since = parseSince(input.since, now4);
|
|
6373
6487
|
const inWindow = (at) => typeof at === "string" && Date.parse(at) >= since.getTime() && Date.parse(at) <= now4.getTime();
|
|
6374
|
-
const events2 = readLoopEvents(loaded.stateDir).filter((event2) => inWindow(event2.at));
|
|
6488
|
+
const events2 = readLoopEvents(loaded.stateDir, since.getTime()).filter((event2) => inWindow(event2.at));
|
|
6375
6489
|
const counts = {};
|
|
6376
6490
|
for (const event2 of events2) counts[event2.type] = (counts[event2.type] ?? 0) + 1;
|
|
6377
6491
|
const escalations = events2.filter((event2) => event2.type === "contract.escalated");
|
|
@@ -6393,6 +6507,8 @@ var buildRetroReport = async (input) => {
|
|
|
6393
6507
|
if (existsSync(issuesDir)) for (const entry of readdirSync(issuesDir, { withFileTypes: true })) {
|
|
6394
6508
|
if (!entry.isDirectory()) continue;
|
|
6395
6509
|
const issue = entry.name;
|
|
6510
|
+
const newestMtime = newestIssueMtimeMs(loaded.stateDir, issue);
|
|
6511
|
+
if (newestMtime !== null && newestMtime < since.getTime()) continue;
|
|
6396
6512
|
const dispatch = readDispatchRecord(loaded.stateDir, issue);
|
|
6397
6513
|
const delivery2 = readDeliveryState(loaded.stateDir, issue);
|
|
6398
6514
|
const contract = readStoredContract(loaded.stateDir, issue);
|
|
@@ -6450,7 +6566,7 @@ var buildRetroReport = async (input) => {
|
|
|
6450
6566
|
else if (status2 === "ok") work += 1;
|
|
6451
6567
|
}
|
|
6452
6568
|
}
|
|
6453
|
-
orca = { runs, idle, work, timedOut, avgDurationSec: durations.length ? Math.round(durations.reduce((
|
|
6569
|
+
orca = { runs, idle, work, timedOut, avgDurationSec: durations.length ? Math.round(durations.reduce((sum2, value) => sum2 + value, 0) / durations.length) : null, maxDurationSec: durations.length ? Math.round(Math.max(...durations)) : null };
|
|
6454
6570
|
} catch {
|
|
6455
6571
|
orca = null;
|
|
6456
6572
|
}
|
|
@@ -6459,9 +6575,9 @@ var buildRetroReport = async (input) => {
|
|
|
6459
6575
|
generatedAt: now4.toISOString(),
|
|
6460
6576
|
window: { since: since.toISOString(), until: now4.toISOString(), days: Number(((now4.getTime() - since.getTime()) / 864e5).toFixed(2)) },
|
|
6461
6577
|
project: config.project.repo,
|
|
6462
|
-
person
|
|
6578
|
+
person,
|
|
6463
6579
|
counts,
|
|
6464
|
-
escalations: { total: escalations.length, issues: [...new Set(escalations.map((event2) => String(event2.issue ?? "?")))], reasons: [...reasonCounts.entries()].map(([reason2,
|
|
6580
|
+
escalations: { total: escalations.length, issues: [...new Set(escalations.map((event2) => String(event2.issue ?? "?")))], reasons: [...reasonCounts.entries()].map(([reason2, count3]) => ({ reason: reason2, count: count3 })).sort((left, right) => right.count - left.count) },
|
|
6465
6581
|
dispatches: { total: dispatchEvents.length, failed: counts["worker.dispatch-failed"] ?? 0, byProvider },
|
|
6466
6582
|
delivery: { merged: tally("merged"), blocked: tally("blocked"), stuck: tally("stuck"), abandoned: tally("abandoned"), inFlight: tally("in-flight"), fixRounds, reviewsClean, reviewsFindings, reviewsIncomplete, medianLeadTimeMin: median2(rows.map((row) => row.leadTimeMin).filter((value) => value !== null)) },
|
|
6467
6583
|
providers: { cooldowns, cooldownEvents: counts["provider.cooldown"] ?? 0 },
|
|
@@ -6488,7 +6604,7 @@ var renderRetroMarkdown = (report) => {
|
|
|
6488
6604
|
if (report.orca) lines.push(`| Orca runs (idle / work / timed out) | ${report.orca.runs} (${report.orca.idle} / ${report.orca.work} / ${report.orca.timedOut}) \xB7 avg ${report.orca.avgDurationSec ?? "\u2014"} s \xB7 max ${report.orca.maxDurationSec ?? "\u2014"} s |`);
|
|
6489
6605
|
lines.push("");
|
|
6490
6606
|
if (Object.keys(report.dispatches.byProvider).length) {
|
|
6491
|
-
lines.push("## Providers", "", ...Object.entries(report.dispatches.byProvider).map(([key,
|
|
6607
|
+
lines.push("## Providers", "", ...Object.entries(report.dispatches.byProvider).map(([key, count3]) => `- ${key}: ${count3} dispatch(es)`), ...report.providers.cooldowns.map((row) => `- cooldown ${row.provider} until ${row.until.slice(0, 16)}Z \u2014 ${row.reason}`), "");
|
|
6492
6608
|
}
|
|
6493
6609
|
if (report.escalations.reasons.length) {
|
|
6494
6610
|
lines.push("## Problems", "", ...report.escalations.reasons.map((row) => `- ${row.count}\xD7 ${row.reason}`), ...report.issues.filter((row) => ["blocked", "stuck", "abandoned"].includes(row.outcome)).map((row) => `- ${row.issue} ${row.outcome}${row.pr ? ` (PR #${row.pr})` : ""} after ${row.fixRounds} fix round(s), ${row.nudges} nudge(s)`), "");
|
|
@@ -6669,7 +6785,7 @@ var buildDebriefReport = (input) => {
|
|
|
6669
6785
|
}
|
|
6670
6786
|
const inFlight = rows.filter((row) => !row.finalOutcome && row.phase !== "escalated");
|
|
6671
6787
|
const held = rows.filter((row) => row.phase === "held" || row.phase === "held-incomplete-review" || row.heldFor);
|
|
6672
|
-
const events2 = readLoopEvents(stateDir).filter((event2) => Date.parse(event2.at) >= since.getTime());
|
|
6788
|
+
const events2 = readLoopEvents(stateDir, since.getTime()).filter((event2) => Date.parse(event2.at) >= since.getTime());
|
|
6673
6789
|
const recentEscalations = events2.filter((event2) => event2.type === "contract.escalated").slice(-10).map((event2) => ({
|
|
6674
6790
|
issue: typeof event2.issue === "string" ? event2.issue : "?",
|
|
6675
6791
|
at: event2.at,
|
|
@@ -6751,6 +6867,138 @@ var renderDebriefMarkdown = (report) => {
|
|
|
6751
6867
|
lines.push("_Read-only. Run `ak-harness loop deliver` / `tick` to act; `loop retro` for the weekly digest._");
|
|
6752
6868
|
return lines.join("\n");
|
|
6753
6869
|
};
|
|
6870
|
+
var connectedStatuses = /* @__PURE__ */ new Set(["connected", "running", "active", "idle"]);
|
|
6871
|
+
var stalledPhases = /* @__PURE__ */ new Set(["waiting-for-pr", "awaiting-review", "review-incomplete", "fix-round"]);
|
|
6872
|
+
var heldRow = (row) => Boolean(row.heldFor) || row.phase === "held" || row.phase === "held-incomplete-review";
|
|
6873
|
+
var count2 = (events2, type) => events2.filter((event2) => event2.type === type).length;
|
|
6874
|
+
var sum = (events2, key) => events2.reduce((total, event2) => total + (typeof event2[key] === "number" && Number.isFinite(event2[key]) ? Number(event2[key]) : 0), 0);
|
|
6875
|
+
var uniqueIssues = (events2, types) => new Set(events2.filter((event2) => types.includes(event2.type) && typeof event2.issue === "string").map((event2) => event2.issue)).size;
|
|
6876
|
+
var assessObservability = (input) => {
|
|
6877
|
+
const anomalies = [];
|
|
6878
|
+
for (const issue of input.missingDeliveryIssues) anomalies.push({ id: "claim-without-delivery", severity: "action_required", issue, message: `${issue} has an active claim but no delivery.json`, evidence: { issue } });
|
|
6879
|
+
for (const terminal2 of input.terminals) {
|
|
6880
|
+
if (terminal2.worktreeId && connectedStatuses.has(terminal2.status.toLowerCase()) && terminal2.lastOutputAt === null && !terminal2.preview.trim()) anomalies.push({ id: "connected-without-output", severity: "warning", issue: null, message: `terminal ${terminal2.handle} is connected but has not emitted output`, evidence: { handle: terminal2.handle, worktreeId: terminal2.worktreeId, status: terminal2.status } });
|
|
6881
|
+
}
|
|
6882
|
+
for (const worktree of input.finalizedDirtyWorktrees) anomalies.push({ id: "finalized-dirty-worktree", severity: "action_required", issue: worktree.issue, message: `finalized worktree ${worktree.worktreeId} still has ${worktree.files} uncommitted file(s)`, evidence: { ...worktree } });
|
|
6883
|
+
const latestDispatch = input.events.filter((event2) => event2.type === "worker.dispatched").map((event2) => Date.parse(event2.at)).filter(Number.isFinite).sort((a, b) => b - a)[0];
|
|
6884
|
+
const quietForMin = latestDispatch === void 0 ? Number.POSITIVE_INFINITY : Math.max(0, Math.round((Date.parse(input.generatedAt) - latestDispatch) / 6e4));
|
|
6885
|
+
if (input.queueReady > 0 && input.freeSlots > 0 && quietForMin >= 15) anomalies.push({ id: "queue-ready-no-dispatch", severity: "action_required", issue: null, message: `${input.queueReady} ready issue(s) and ${input.freeSlots} free slot(s), but no dispatch in ${Number.isFinite(quietForMin) ? `${quietForMin} min` : "the observation window"}`, evidence: { queueReady: input.queueReady, freeSlots: input.freeSlots, quietForMin } });
|
|
6886
|
+
for (const row of input.issues) {
|
|
6887
|
+
if (row.heldFor || !stalledPhases.has(row.phase) || row.ageMin === null || row.ageMin < input.workerIdleTimeoutMin) continue;
|
|
6888
|
+
anomalies.push({ id: "stalled-delivery", severity: "action_required", issue: row.issue, message: `${row.issue} is in ${row.phase} for ${row.ageMin} min (threshold ${input.workerIdleTimeoutMin} min)`, evidence: { issue: row.issue, phase: row.phase, ageMin: row.ageMin, thresholdMin: input.workerIdleTimeoutMin } });
|
|
6889
|
+
}
|
|
6890
|
+
const events2 = {};
|
|
6891
|
+
for (const event2 of input.events) events2[event2.type] = (events2[event2.type] ?? 0) + 1;
|
|
6892
|
+
const report = {
|
|
6893
|
+
status: anomalies.some((item) => item.severity === "action_required") ? "action_required" : "healthy",
|
|
6894
|
+
generatedAt: input.generatedAt,
|
|
6895
|
+
project: input.project,
|
|
6896
|
+
person: input.person,
|
|
6897
|
+
windowHours: input.windowHours,
|
|
6898
|
+
anomalies,
|
|
6899
|
+
metrics: {
|
|
6900
|
+
queueReady: input.queueReady,
|
|
6901
|
+
freeSlots: input.freeSlots,
|
|
6902
|
+
runningWorkers: input.runningWorkers,
|
|
6903
|
+
maxAgents: input.maxAgents,
|
|
6904
|
+
activeClaims: input.activeClaims,
|
|
6905
|
+
inFlight: input.issues.filter((row) => !heldRow(row)).length,
|
|
6906
|
+
held: input.issues.filter(heldRow).length,
|
|
6907
|
+
merged: input.merged,
|
|
6908
|
+
blocked: input.blocked,
|
|
6909
|
+
fixRounds: input.fixRounds,
|
|
6910
|
+
reviewFindings: input.reviewFindings,
|
|
6911
|
+
reviewIncomplete: input.reviewIncomplete,
|
|
6912
|
+
medianLeadTimeMin: input.medianLeadTimeMin,
|
|
6913
|
+
providerRemainingPercent: input.providerRemainingPercent,
|
|
6914
|
+
machine: input.machine,
|
|
6915
|
+
memory: input.memory,
|
|
6916
|
+
cache: input.cache,
|
|
6917
|
+
tokens: input.tokens,
|
|
6918
|
+
events: events2
|
|
6919
|
+
}
|
|
6920
|
+
};
|
|
6921
|
+
return report;
|
|
6922
|
+
};
|
|
6923
|
+
var compactTerminal = (terminal2) => ({ handle: terminal2.handle, status: terminal2.status, worktreeId: terminal2.worktreeId, lastOutputAt: terminal2.lastOutputAt, preview: terminal2.preview });
|
|
6924
|
+
var dirtyFinalizedWorktrees = async (runner, worktrees) => {
|
|
6925
|
+
const out = [];
|
|
6926
|
+
for (const worktree of worktrees) {
|
|
6927
|
+
if (worktree.workspaceStatus.trim().toLowerCase() !== "completed" || !worktree.path) continue;
|
|
6928
|
+
try {
|
|
6929
|
+
const result = await runner.run(["git", "-C", worktree.path, "status", "--porcelain"], { timeoutMs: 1e4 });
|
|
6930
|
+
if (result.code === 0 && result.stdout.trim()) out.push({ worktreeId: worktree.id, issue: worktree.linkedLinearIssue, files: result.stdout.trim().split(/\r?\n/).length });
|
|
6931
|
+
} catch {
|
|
6932
|
+
}
|
|
6933
|
+
}
|
|
6934
|
+
return out;
|
|
6935
|
+
};
|
|
6936
|
+
var runObservability = async (input) => {
|
|
6937
|
+
const loaded = input.loaded ?? loadLoopConfig(input.configPath ?? "loop.config.yaml");
|
|
6938
|
+
const now4 = input.now ?? (() => /* @__PURE__ */ new Date());
|
|
6939
|
+
const at = now4();
|
|
6940
|
+
const since = parseSince(input.since ?? "24h", at);
|
|
6941
|
+
const [doctor, debrief, worktrees, terminals] = await Promise.all([
|
|
6942
|
+
runLoopDoctor({ loaded, runner: input.runner, env: input.env, platform: input.platform, now: () => at, probe: false }),
|
|
6943
|
+
Promise.resolve(buildDebriefReport({ loaded, since: input.since ?? "24h", now: () => at })),
|
|
6944
|
+
orcaWorktrees(input.runner, { bin: loaded.config.orca.bin, timeoutMs: loaded.config.orca.timeoutMs }).catch(() => []),
|
|
6945
|
+
orcaTerminalList(input.runner, {}, { bin: loaded.config.orca.bin, timeoutMs: loaded.config.orca.timeoutMs }).catch(() => [])
|
|
6946
|
+
]);
|
|
6947
|
+
const events2 = readLoopEvents(loaded.stateDir).filter((event2) => Date.parse(event2.at) >= since.getTime() && Date.parse(event2.at) <= at.getTime());
|
|
6948
|
+
const ledger = createDispatchLedger(loaded.stateDir);
|
|
6949
|
+
const active = ledger.active();
|
|
6950
|
+
const missingDeliveryIssues = active.filter((lease) => !existsSync(deliveryStatePath(loaded.stateDir, lease.issue))).map((lease) => lease.issue);
|
|
6951
|
+
const records = listDispatched(loaded.stateDir);
|
|
6952
|
+
const completed = records.map((record3) => ({ record: record3, state: readDeliveryState(loaded.stateDir, record3.issue) })).filter(({ state }) => state.finishedAt && Date.parse(state.finishedAt) >= since.getTime());
|
|
6953
|
+
const leadTimes = completed.map(({ record: record3, state }) => state.finishedAt ? (Date.parse(state.finishedAt) - Date.parse(record3.dispatchedAt)) / 6e4 : null).filter((value) => value !== null && Number.isFinite(value)).sort((a, b) => a - b);
|
|
6954
|
+
const medianLeadTimeMin = leadTimes.length ? leadTimes.length % 2 ? leadTimes[Math.floor(leadTimes.length / 2)] : (leadTimes[leadTimes.length / 2 - 1] + leadTimes[leadTimes.length / 2]) / 2 : null;
|
|
6955
|
+
const providerRemainingPercent = Object.fromEntries(doctor.providers.map((provider) => [provider.id, remainingUsagePercent(provider.usage, loaded.config.models.routing.usageMetric)]));
|
|
6956
|
+
const cachedContracts = records.filter((record3) => existsSync(contractPath(loaded.stateDir, record3.issue))).length;
|
|
6957
|
+
const memoryEvents = events2.filter((event2) => event2.type === "memory.recalled");
|
|
6958
|
+
const tokens = { input: sum(events2, "inputTokens"), output: sum(events2, "outputTokens"), total: sum(events2, "totalTokens"), cacheRead: sum(events2, "cacheReadTokens"), cacheWrite: sum(events2, "cacheWriteTokens") };
|
|
6959
|
+
const machine = { cpuCount: doctor.machine.sample.cpus, load1PerCpuPercent: doctor.machine.sample.load1PerCpuPercent, memoryUsedPercent: doctor.machine.sample.memoryUsedPercent, freeRamGb: doctor.machine.freeRamGb };
|
|
6960
|
+
const snapshot = {
|
|
6961
|
+
generatedAt: at.toISOString(),
|
|
6962
|
+
project: doctor.config.project,
|
|
6963
|
+
person: doctor.config.person,
|
|
6964
|
+
windowHours: Math.max(1, Math.round((at.getTime() - since.getTime()) / 36e5)),
|
|
6965
|
+
workerIdleTimeoutMin: loaded.config.delivery.workerIdleTimeoutMin,
|
|
6966
|
+
queueReady: doctor.queue.count,
|
|
6967
|
+
freeSlots: doctor.machine.free,
|
|
6968
|
+
runningWorkers: doctor.workers.running,
|
|
6969
|
+
maxAgents: doctor.machine.maxAgents,
|
|
6970
|
+
activeClaims: active.length,
|
|
6971
|
+
missingDeliveryIssues,
|
|
6972
|
+
terminals: terminals.map(compactTerminal),
|
|
6973
|
+
finalizedDirtyWorktrees: await dirtyFinalizedWorktrees(input.runner, worktrees),
|
|
6974
|
+
issues: debrief.inFlight.map(({ issue, phase, ageMin, heldFor }) => ({ issue, phase, ageMin, heldFor })),
|
|
6975
|
+
events: events2,
|
|
6976
|
+
merged: uniqueIssues(events2, ["pr.merged", "worker.merged"]),
|
|
6977
|
+
blocked: Math.max(records.filter((record3) => readDeliveryState(loaded.stateDir, record3.issue).finalOutcome === "blocked").length, uniqueIssues(events2, ["worker.blocked"])),
|
|
6978
|
+
fixRounds: records.reduce((total, record3) => total + readDeliveryState(loaded.stateDir, record3.issue).fixRounds, 0),
|
|
6979
|
+
reviewFindings: count2(events2, "pr.reviewed") - count2(events2.filter((event2) => event2["status"] !== "findings"), "pr.reviewed"),
|
|
6980
|
+
reviewIncomplete: events2.filter((event2) => event2.type === "pr.reviewed" && event2["status"] === "incomplete").length,
|
|
6981
|
+
medianLeadTimeMin,
|
|
6982
|
+
providerRemainingPercent,
|
|
6983
|
+
machine,
|
|
6984
|
+
memory: { recalls: memoryEvents.length, hits: sum(memoryEvents, "hits"), approxCharsSaved: sum(memoryEvents, "approxCharsSaved") },
|
|
6985
|
+
cache: { cachedContracts },
|
|
6986
|
+
tokens
|
|
6987
|
+
};
|
|
6988
|
+
return assessObservability(snapshot);
|
|
6989
|
+
};
|
|
6990
|
+
var renderObservabilityMarkdown = (report) => {
|
|
6991
|
+
const m = report.metrics;
|
|
6992
|
+
const headroom = Object.entries(m.providerRemainingPercent).map(([provider, remaining]) => `${provider} ${remaining === null ? "?" : `${remaining}%`}`).join(", ");
|
|
6993
|
+
const lines = [`# Loop observability \u2014 ${report.project} \xB7 ${report.person}`, "", `_${report.status}_ \xB7 generated ${report.generatedAt.slice(0, 19)}Z \xB7 last ${report.windowHours}h`, "", "## Metrics", "", `- Queue: ${m.queueReady} ready \xB7 ${m.freeSlots} free slot(s) \xB7 ${m.runningWorkers}/${m.maxAgents} workers`, `- Delivery: ${m.inFlight} in flight \xB7 ${m.held} held \xB7 ${m.merged} merged \xB7 ${m.blocked} blocked \xB7 ${m.fixRounds} fix round(s)`, `- Reviews: ${m.reviewFindings} findings \xB7 ${m.reviewIncomplete} incomplete`, `- Machine: ${m.machine.cpuCount} CPU \xB7 ${m.machine.load1PerCpuPercent}% load \xB7 ${m.machine.memoryUsedPercent}% memory \xB7 ${m.machine.freeRamGb} GB free`, `- Providers: ${headroom || "n/a"}`, `- Memory/cache: ${m.memory.recalls} recall(s), ${m.memory.hits} hit(s), ${m.memory.approxCharsSaved} chars saved \xB7 ${m.cache.cachedContracts} cached contract(s)`, `- Tokens observed: ${m.tokens.total || m.tokens.input + m.tokens.output || "n/a"}`, ""];
|
|
6994
|
+
if (report.anomalies.length) {
|
|
6995
|
+
lines.push("## Anomalies", "");
|
|
6996
|
+
for (const anomaly of report.anomalies) lines.push(`- **${anomaly.severity}**${anomaly.issue ? ` \xB7 ${anomaly.issue}` : ""}: ${anomaly.message}`);
|
|
6997
|
+
lines.push("");
|
|
6998
|
+
} else lines.push("## Anomalies", "", "_None detected._", "");
|
|
6999
|
+
lines.push("_Read-only. Run `ak-harness loop tick` or `deliver` to act on the queue._");
|
|
7000
|
+
return lines.join("\n");
|
|
7001
|
+
};
|
|
6754
7002
|
|
|
6755
7003
|
// src/loop/watch.ts
|
|
6756
7004
|
var defaultSleep = (ms) => new Promise((resolve9) => setTimeout(resolve9, ms));
|
|
@@ -6887,6 +7135,32 @@ var print = (value) => {
|
|
|
6887
7135
|
if (options().json) console.log(JSON.stringify(value));
|
|
6888
7136
|
else console.log(typeof value === "string" ? value : JSON.stringify(value, null, 2));
|
|
6889
7137
|
};
|
|
7138
|
+
var acquireStageLock = (stateDir, stage) => {
|
|
7139
|
+
const path = join(stateDir, `.stage-${stage}.lock`);
|
|
7140
|
+
mkdirSync(stateDir, { recursive: true });
|
|
7141
|
+
try {
|
|
7142
|
+
const fd = openSync(path, "wx");
|
|
7143
|
+
writeFileSync(fd, `${JSON.stringify({ pid: process.pid, stage, at: (/* @__PURE__ */ new Date()).toISOString() })}
|
|
7144
|
+
`, "utf8");
|
|
7145
|
+
closeSync(fd);
|
|
7146
|
+
return () => {
|
|
7147
|
+
try {
|
|
7148
|
+
unlinkSync(path);
|
|
7149
|
+
} catch {
|
|
7150
|
+
}
|
|
7151
|
+
};
|
|
7152
|
+
} catch (error) {
|
|
7153
|
+
if (error.code !== "EEXIST") throw error;
|
|
7154
|
+
try {
|
|
7155
|
+
if (Date.now() - statSync(path).mtimeMs > 30 * 6e4) {
|
|
7156
|
+
unlinkSync(path);
|
|
7157
|
+
return acquireStageLock(stateDir, stage);
|
|
7158
|
+
}
|
|
7159
|
+
} catch {
|
|
7160
|
+
}
|
|
7161
|
+
return null;
|
|
7162
|
+
}
|
|
7163
|
+
};
|
|
6890
7164
|
var readBenchmarkEvidence = (path) => {
|
|
6891
7165
|
try {
|
|
6892
7166
|
const content = readFileSync(path, "utf8");
|
|
@@ -6991,6 +7265,12 @@ loop.command("stage <stage>").description("Run one stage (tick | deliver | retro
|
|
|
6991
7265
|
process.exitCode = 1;
|
|
6992
7266
|
return;
|
|
6993
7267
|
}
|
|
7268
|
+
const stageLock = acquireStageLock(loaded.stateDir, stage);
|
|
7269
|
+
if (!stageLock) {
|
|
7270
|
+
console.log(JSON.stringify({ status: "locked", stage, reason: "another stage run is still active" }, null, 2));
|
|
7271
|
+
process.exitCode = 1;
|
|
7272
|
+
return;
|
|
7273
|
+
}
|
|
6994
7274
|
const budgetMs = Math.max(6e4, loaded.config.schedule.stageTimeoutSec * 1e3 - 6e4);
|
|
6995
7275
|
const threshold = loaded.config.resilience.stagePauseAfterRuns;
|
|
6996
7276
|
try {
|
|
@@ -7001,6 +7281,8 @@ loop.command("stage <stage>").description("Run one stage (tick | deliver | retro
|
|
|
7001
7281
|
const reason = error instanceof Error ? error.message : String(error);
|
|
7002
7282
|
const entry = stage !== "retro" ? recordStageRunResult(loaded.stateDir, trackedStage, { succeeded: false, reason }, threshold) : null;
|
|
7003
7283
|
console.log(JSON.stringify({ status: "error", stage, error: reason, ...entry ? { consecutiveFailures: entry.consecutiveFailures, paused: entry.pausedAt !== null } : {} }, null, 2));
|
|
7284
|
+
} finally {
|
|
7285
|
+
stageLock();
|
|
7004
7286
|
}
|
|
7005
7287
|
process.exitCode = 1;
|
|
7006
7288
|
});
|
|
@@ -7080,6 +7362,13 @@ loop.command("debrief").description("Human-facing explanation of what the loop i
|
|
|
7080
7362
|
if (options().json) return print(report);
|
|
7081
7363
|
console.log(renderDebriefMarkdown(report));
|
|
7082
7364
|
});
|
|
7365
|
+
loop.command("observe").description("Read-only anomaly scan and operating metrics for the loop (queue, workers, delivery, machine, memory, cache, tokens).").option("--since <window>", "window such as 24h, 7d or an ISO date", "24h").option("--precheck", "exit 0 when an action is required, 1 when healthy (for schedulers)").action(async function(command) {
|
|
7366
|
+
const report = await runObservability({ configPath: loopFile(this), runner: createProcessRunner(), since: command.since });
|
|
7367
|
+
if (options().json) print(report);
|
|
7368
|
+
else console.log(renderObservabilityMarkdown(report));
|
|
7369
|
+
if (command.precheck) process.exitCode = report.status === "action_required" ? 0 : 1;
|
|
7370
|
+
else if (report.status === "action_required") process.exitCode = 2;
|
|
7371
|
+
});
|
|
7083
7372
|
loop.command("watch").description("Watch delivery.json (+ optional live PR) for in-flight issues; prints DONE / FAILED / ACTION_REQUIRED / PROGRESS. Read-only.").option("--issue <identifier>", "restrict to one issue").option("--interval <seconds>", "poll interval", (value) => Number(value), 30).option("--once", "single snapshot then exit").option("--timeout <seconds>", "stop after N seconds (0 = until terminal)", (value) => Number(value), 0).option("--no-live-pr", "do not call gh; filesystem state only").action(async function(command) {
|
|
7084
7373
|
const report = await watchDeliveries({
|
|
7085
7374
|
configPath: loopFile(this),
|