@njeybe/nativ 2.5.1 → 2.6.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 +32 -0
- package/README.md +29 -4
- package/dist/commands/init.js +2 -10
- package/dist/commands/notify.d.ts +2 -0
- package/dist/commands/notify.js +25 -0
- package/dist/commands/session.d.ts +22 -0
- package/dist/commands/session.js +106 -0
- package/dist/commands/task/task-add.js +1 -1
- package/dist/commands/task/task-common.js +6 -6
- package/dist/commands/task/task-escalate.js +8 -10
- package/dist/commands/task/task-lifecycle.d.ts +7 -1
- package/dist/commands/task/task-lifecycle.js +30 -5
- package/dist/commands/task/task-query.d.ts +2 -0
- package/dist/commands/task/task-query.js +27 -12
- package/dist/commands/task/task-reclaim.d.ts +20 -0
- package/dist/commands/task/task-reclaim.js +87 -0
- package/dist/commands/task.d.ts +2 -0
- package/dist/commands/task.js +1 -0
- package/dist/commands/triage.js +3 -3
- package/dist/commands/validate.js +1 -0
- package/dist/commands/verify.js +21 -5
- package/dist/core/enforcement.js +18 -17
- package/dist/core/lock-manager.d.ts +6 -0
- package/dist/core/lock-manager.js +50 -41
- package/dist/core/notify.d.ts +38 -0
- package/dist/core/notify.js +133 -0
- package/dist/core/profiles.js +7 -16
- package/dist/core/session-memory.d.ts +42 -0
- package/dist/core/session-memory.js +121 -0
- package/dist/core/setup/pm-playbook.d.ts +5 -0
- package/dist/core/setup/pm-playbook.js +26 -0
- package/dist/core/setup/session-context.js +35 -2
- package/dist/core/setup/setup-planner.js +2 -2
- package/dist/core/setup/types.d.ts +2 -0
- package/dist/core/setup/types.js +5 -1
- package/dist/core/task-claims.d.ts +15 -0
- package/dist/core/task-claims.js +32 -0
- package/dist/core/tier1-liaison.d.ts +1 -0
- package/dist/core/tier1-liaison.js +25 -27
- package/dist/core/verifier/output-digest.d.ts +19 -0
- package/dist/core/verifier/output-digest.js +48 -0
- package/dist/db/data-engine.js +33 -32
- package/dist/db/introspector.js +61 -60
- package/dist/db/sqlite.d.ts +3 -0
- package/dist/db/sqlite.js +10 -0
- package/dist/governor/circuit-breaker.js +12 -0
- package/dist/governor/patch-applier.d.ts +1 -0
- package/dist/governor/patch-applier.js +3 -7
- package/dist/index.js +29 -6
- package/dist/mcp/server.d.ts +1 -1
- package/dist/mcp/server.js +53 -14
- package/dist/providers/claude-api.js +3 -0
- package/dist/providers/gemini.js +4 -1
- package/dist/providers/registry.d.ts +6 -2
- package/dist/providers/registry.js +28 -16
- package/dist/providers/types.d.ts +4 -1
- package/dist/runner/agent-supervisor.js +2 -2
- package/dist/runner/cli-config.js +1 -1
- package/dist/scanner/types.d.ts +5 -1
- package/dist/scanner/types.js +7 -0
- package/dist/server/routes/task-routes.d.ts +2 -0
- package/dist/server/routes/triage-routes.js +3 -18
- package/package.json +2 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/agents/architect.md +1 -1
- package/plugin/agents/explorer.md +1 -1
- package/plugin/agents/verifier.md +2 -2
- package/plugin/agents/worker.md +5 -7
- package/plugin/skills/nativ/SKILL.md +36 -49
- package/templates/AGENTS.md +36 -49
- package/templates/CLAUDE.md +1 -41
- package/templates/claude-agents/architect.md +1 -1
- package/templates/claude-agents/explorer.md +1 -1
- package/templates/claude-agents/verifier.md +2 -2
- package/templates/claude-agents/worker.md +5 -7
- package/templates/dot-ai/api_contracts.json +66 -66
- package/templates/dot-ai/context.md +38 -38
- package/templates/dot-ai/db_schema.json +39 -39
package/dist/db/introspector.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { performance } from 'node:perf_hooks';
|
|
2
2
|
import { isNoSqlEngine } from './types.js';
|
|
3
3
|
import { databaseNameFromUrl, detectEngine, maskConnectionString, MASK, parseFirestoreUrl, redactMediaUrl, sqlitePathFromUrl, } from './env-parser.js';
|
|
4
|
+
import { loadSqlite } from './sqlite.js';
|
|
4
5
|
/**
|
|
5
6
|
* Multi-engine schema introspector.
|
|
6
7
|
* Reads structural metadata only (tables, columns, types, nullability, keys, indexes).
|
|
@@ -81,30 +82,30 @@ async function createPostgresDriver(url) {
|
|
|
81
82
|
},
|
|
82
83
|
async tables() {
|
|
83
84
|
const builder = new TableBuilder();
|
|
84
|
-
const tables = await client.query(`
|
|
85
|
-
SELECT t.table_name, obj_description(format('%I.%I', t.table_schema, t.table_name)::regclass, 'pg_class') AS description
|
|
86
|
-
FROM information_schema.tables t
|
|
87
|
-
WHERE t.table_schema = current_schema() AND t.table_type = 'BASE TABLE'
|
|
85
|
+
const tables = await client.query(`
|
|
86
|
+
SELECT t.table_name, obj_description(format('%I.%I', t.table_schema, t.table_name)::regclass, 'pg_class') AS description
|
|
87
|
+
FROM information_schema.tables t
|
|
88
|
+
WHERE t.table_schema = current_schema() AND t.table_type = 'BASE TABLE'
|
|
88
89
|
ORDER BY t.table_name`);
|
|
89
90
|
for (const row of tables.rows) {
|
|
90
91
|
const table = builder.get(row.table_name);
|
|
91
92
|
if (row.description)
|
|
92
93
|
table.description = row.description;
|
|
93
94
|
}
|
|
94
|
-
const pks = await client.query(`
|
|
95
|
-
SELECT kcu.table_name, kcu.column_name
|
|
96
|
-
FROM information_schema.table_constraints tc
|
|
97
|
-
JOIN information_schema.key_column_usage kcu
|
|
98
|
-
ON kcu.constraint_name = tc.constraint_name AND kcu.table_schema = tc.table_schema AND kcu.table_name = tc.table_name
|
|
95
|
+
const pks = await client.query(`
|
|
96
|
+
SELECT kcu.table_name, kcu.column_name
|
|
97
|
+
FROM information_schema.table_constraints tc
|
|
98
|
+
JOIN information_schema.key_column_usage kcu
|
|
99
|
+
ON kcu.constraint_name = tc.constraint_name AND kcu.table_schema = tc.table_schema AND kcu.table_name = tc.table_name
|
|
99
100
|
WHERE tc.table_schema = current_schema() AND tc.constraint_type = 'PRIMARY KEY'`);
|
|
100
101
|
const pkSet = new Set(pks.rows.map((r) => `${r.table_name}.${r.column_name}`));
|
|
101
|
-
const columns = await client.query(`
|
|
102
|
-
SELECT c.table_name, c.column_name, c.data_type, c.udt_name, c.character_maximum_length,
|
|
103
|
-
c.numeric_precision, c.numeric_scale, c.is_nullable, c.column_default
|
|
104
|
-
FROM information_schema.columns c
|
|
105
|
-
JOIN information_schema.tables t
|
|
106
|
-
ON t.table_schema = c.table_schema AND t.table_name = c.table_name AND t.table_type = 'BASE TABLE'
|
|
107
|
-
WHERE c.table_schema = current_schema()
|
|
102
|
+
const columns = await client.query(`
|
|
103
|
+
SELECT c.table_name, c.column_name, c.data_type, c.udt_name, c.character_maximum_length,
|
|
104
|
+
c.numeric_precision, c.numeric_scale, c.is_nullable, c.column_default
|
|
105
|
+
FROM information_schema.columns c
|
|
106
|
+
JOIN information_schema.tables t
|
|
107
|
+
ON t.table_schema = c.table_schema AND t.table_name = c.table_name AND t.table_type = 'BASE TABLE'
|
|
108
|
+
WHERE c.table_schema = current_schema()
|
|
108
109
|
ORDER BY c.table_name, c.ordinal_position`);
|
|
109
110
|
for (const row of columns.rows) {
|
|
110
111
|
builder.get(row.table_name).columns.push({
|
|
@@ -116,31 +117,31 @@ async function createPostgresDriver(url) {
|
|
|
116
117
|
});
|
|
117
118
|
}
|
|
118
119
|
// information_schema has no index view — pg_catalog is the only source.
|
|
119
|
-
const indexes = await client.query(`
|
|
120
|
-
SELECT t.relname AS table_name, i.relname AS index_name, ix.indisunique AS is_unique,
|
|
121
|
-
(SELECT array_agg(a.attname::text ORDER BY k.ord)
|
|
122
|
-
FROM unnest(ix.indkey::int2[]) WITH ORDINALITY AS k(attnum, ord)
|
|
123
|
-
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum) AS columns
|
|
124
|
-
FROM pg_index ix
|
|
125
|
-
JOIN pg_class t ON t.oid = ix.indrelid
|
|
126
|
-
JOIN pg_class i ON i.oid = ix.indexrelid
|
|
127
|
-
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
128
|
-
WHERE n.nspname = current_schema() AND NOT ix.indisprimary AND t.relkind IN ('r', 'p')
|
|
120
|
+
const indexes = await client.query(`
|
|
121
|
+
SELECT t.relname AS table_name, i.relname AS index_name, ix.indisunique AS is_unique,
|
|
122
|
+
(SELECT array_agg(a.attname::text ORDER BY k.ord)
|
|
123
|
+
FROM unnest(ix.indkey::int2[]) WITH ORDINALITY AS k(attnum, ord)
|
|
124
|
+
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum) AS columns
|
|
125
|
+
FROM pg_index ix
|
|
126
|
+
JOIN pg_class t ON t.oid = ix.indrelid
|
|
127
|
+
JOIN pg_class i ON i.oid = ix.indexrelid
|
|
128
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
129
|
+
WHERE n.nspname = current_schema() AND NOT ix.indisprimary AND t.relkind IN ('r', 'p')
|
|
129
130
|
ORDER BY t.relname, i.relname`);
|
|
130
131
|
for (const row of indexes.rows) {
|
|
131
132
|
builder.get(row.table_name).indexes.push({ name: row.index_name, columns: row.columns ?? [], unique: row.is_unique });
|
|
132
133
|
}
|
|
133
|
-
const fks = await client.query(`
|
|
134
|
-
SELECT con.conname AS constraint_name, cl.relname AS table_name, a.attname AS column_name,
|
|
135
|
-
fcl.relname AS ref_table, fa.attname AS ref_column, con.confdeltype AS on_delete
|
|
136
|
-
FROM pg_constraint con
|
|
137
|
-
JOIN pg_class cl ON cl.oid = con.conrelid
|
|
138
|
-
JOIN pg_namespace n ON n.oid = cl.relnamespace
|
|
139
|
-
JOIN pg_class fcl ON fcl.oid = con.confrelid
|
|
140
|
-
CROSS JOIN LATERAL unnest(con.conkey, con.confkey) AS k(attnum, fattnum)
|
|
141
|
-
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k.attnum
|
|
142
|
-
JOIN pg_attribute fa ON fa.attrelid = con.confrelid AND fa.attnum = k.fattnum
|
|
143
|
-
WHERE n.nspname = current_schema() AND con.contype = 'f'
|
|
134
|
+
const fks = await client.query(`
|
|
135
|
+
SELECT con.conname AS constraint_name, cl.relname AS table_name, a.attname AS column_name,
|
|
136
|
+
fcl.relname AS ref_table, fa.attname AS ref_column, con.confdeltype AS on_delete
|
|
137
|
+
FROM pg_constraint con
|
|
138
|
+
JOIN pg_class cl ON cl.oid = con.conrelid
|
|
139
|
+
JOIN pg_namespace n ON n.oid = cl.relnamespace
|
|
140
|
+
JOIN pg_class fcl ON fcl.oid = con.confrelid
|
|
141
|
+
CROSS JOIN LATERAL unnest(con.conkey, con.confkey) AS k(attnum, fattnum)
|
|
142
|
+
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k.attnum
|
|
143
|
+
JOIN pg_attribute fa ON fa.attrelid = con.confrelid AND fa.attnum = k.fattnum
|
|
144
|
+
WHERE n.nspname = current_schema() AND con.contype = 'f'
|
|
144
145
|
ORDER BY cl.relname, con.conname`);
|
|
145
146
|
for (const row of fks.rows) {
|
|
146
147
|
builder.get(row.table_name).foreignKeys.push({
|
|
@@ -177,22 +178,22 @@ async function createMysqlDriver(url) {
|
|
|
177
178
|
},
|
|
178
179
|
async tables() {
|
|
179
180
|
const builder = new TableBuilder();
|
|
180
|
-
for (const row of await rows(`
|
|
181
|
-
SELECT TABLE_NAME AS table_name, TABLE_COMMENT AS table_comment
|
|
182
|
-
FROM information_schema.TABLES
|
|
183
|
-
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'
|
|
181
|
+
for (const row of await rows(`
|
|
182
|
+
SELECT TABLE_NAME AS table_name, TABLE_COMMENT AS table_comment
|
|
183
|
+
FROM information_schema.TABLES
|
|
184
|
+
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'
|
|
184
185
|
ORDER BY TABLE_NAME`)) {
|
|
185
186
|
const table = builder.get(row.table_name);
|
|
186
187
|
if (row.table_comment)
|
|
187
188
|
table.description = row.table_comment;
|
|
188
189
|
}
|
|
189
|
-
for (const row of await rows(`
|
|
190
|
-
SELECT c.TABLE_NAME AS table_name, c.COLUMN_NAME AS column_name, c.COLUMN_TYPE AS column_type,
|
|
191
|
-
c.IS_NULLABLE AS is_nullable, c.COLUMN_DEFAULT AS column_default, c.COLUMN_KEY AS column_key
|
|
192
|
-
FROM information_schema.COLUMNS c
|
|
193
|
-
JOIN information_schema.TABLES t
|
|
194
|
-
ON t.TABLE_SCHEMA = c.TABLE_SCHEMA AND t.TABLE_NAME = c.TABLE_NAME AND t.TABLE_TYPE = 'BASE TABLE'
|
|
195
|
-
WHERE c.TABLE_SCHEMA = DATABASE()
|
|
190
|
+
for (const row of await rows(`
|
|
191
|
+
SELECT c.TABLE_NAME AS table_name, c.COLUMN_NAME AS column_name, c.COLUMN_TYPE AS column_type,
|
|
192
|
+
c.IS_NULLABLE AS is_nullable, c.COLUMN_DEFAULT AS column_default, c.COLUMN_KEY AS column_key
|
|
193
|
+
FROM information_schema.COLUMNS c
|
|
194
|
+
JOIN information_schema.TABLES t
|
|
195
|
+
ON t.TABLE_SCHEMA = c.TABLE_SCHEMA AND t.TABLE_NAME = c.TABLE_NAME AND t.TABLE_TYPE = 'BASE TABLE'
|
|
196
|
+
WHERE c.TABLE_SCHEMA = DATABASE()
|
|
196
197
|
ORDER BY c.TABLE_NAME, c.ORDINAL_POSITION`)) {
|
|
197
198
|
builder.get(row.table_name).columns.push({
|
|
198
199
|
name: row.column_name,
|
|
@@ -203,10 +204,10 @@ async function createMysqlDriver(url) {
|
|
|
203
204
|
});
|
|
204
205
|
}
|
|
205
206
|
const indexMap = new Map();
|
|
206
|
-
for (const row of await rows(`
|
|
207
|
-
SELECT TABLE_NAME AS table_name, INDEX_NAME AS index_name, NON_UNIQUE AS non_unique, COLUMN_NAME AS column_name
|
|
208
|
-
FROM information_schema.STATISTICS
|
|
209
|
-
WHERE TABLE_SCHEMA = DATABASE() AND INDEX_NAME <> 'PRIMARY'
|
|
207
|
+
for (const row of await rows(`
|
|
208
|
+
SELECT TABLE_NAME AS table_name, INDEX_NAME AS index_name, NON_UNIQUE AS non_unique, COLUMN_NAME AS column_name
|
|
209
|
+
FROM information_schema.STATISTICS
|
|
210
|
+
WHERE TABLE_SCHEMA = DATABASE() AND INDEX_NAME <> 'PRIMARY'
|
|
210
211
|
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX`)) {
|
|
211
212
|
const key = `${row.table_name}.${row.index_name}`;
|
|
212
213
|
let entry = indexMap.get(key);
|
|
@@ -219,13 +220,13 @@ async function createMysqlDriver(url) {
|
|
|
219
220
|
}
|
|
220
221
|
for (const { table, index } of indexMap.values())
|
|
221
222
|
builder.get(table).indexes.push(index);
|
|
222
|
-
for (const row of await rows(`
|
|
223
|
-
SELECT k.CONSTRAINT_NAME AS constraint_name, k.TABLE_NAME AS table_name, k.COLUMN_NAME AS column_name,
|
|
224
|
-
k.REFERENCED_TABLE_NAME AS ref_table, k.REFERENCED_COLUMN_NAME AS ref_column, r.DELETE_RULE AS delete_rule
|
|
225
|
-
FROM information_schema.KEY_COLUMN_USAGE k
|
|
226
|
-
JOIN information_schema.REFERENTIAL_CONSTRAINTS r
|
|
227
|
-
ON r.CONSTRAINT_SCHEMA = k.CONSTRAINT_SCHEMA AND r.CONSTRAINT_NAME = k.CONSTRAINT_NAME AND r.TABLE_NAME = k.TABLE_NAME
|
|
228
|
-
WHERE k.TABLE_SCHEMA = DATABASE() AND k.REFERENCED_TABLE_NAME IS NOT NULL
|
|
223
|
+
for (const row of await rows(`
|
|
224
|
+
SELECT k.CONSTRAINT_NAME AS constraint_name, k.TABLE_NAME AS table_name, k.COLUMN_NAME AS column_name,
|
|
225
|
+
k.REFERENCED_TABLE_NAME AS ref_table, k.REFERENCED_COLUMN_NAME AS ref_column, r.DELETE_RULE AS delete_rule
|
|
226
|
+
FROM information_schema.KEY_COLUMN_USAGE k
|
|
227
|
+
JOIN information_schema.REFERENTIAL_CONSTRAINTS r
|
|
228
|
+
ON r.CONSTRAINT_SCHEMA = k.CONSTRAINT_SCHEMA AND r.CONSTRAINT_NAME = k.CONSTRAINT_NAME AND r.TABLE_NAME = k.TABLE_NAME
|
|
229
|
+
WHERE k.TABLE_SCHEMA = DATABASE() AND k.REFERENCED_TABLE_NAME IS NOT NULL
|
|
229
230
|
ORDER BY k.TABLE_NAME, k.CONSTRAINT_NAME, k.ORDINAL_POSITION`)) {
|
|
230
231
|
builder.get(row.table_name).foreignKeys.push({
|
|
231
232
|
name: row.constraint_name,
|
|
@@ -247,7 +248,7 @@ function quoteIdent(name) {
|
|
|
247
248
|
return `"${name.replace(/"/g, '""')}"`;
|
|
248
249
|
}
|
|
249
250
|
async function createSqliteDriver(url) {
|
|
250
|
-
const { DatabaseSync } = await
|
|
251
|
+
const { DatabaseSync } = await loadSqlite();
|
|
251
252
|
const filePath = sqlitePathFromUrl(url);
|
|
252
253
|
// readOnly: never create a database file or mutate one while introspecting.
|
|
253
254
|
const db = filePath === ':memory:' ? new DatabaseSync(':memory:') : new DatabaseSync(filePath, { readOnly: true });
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** `node:sqlite` ships with Node.js 22.5+; nativ itself supports Node 20, so SQLite projects get a clear message there. */
|
|
2
|
+
export const SQLITE_MIN_NODE = '22.5';
|
|
3
|
+
export async function loadSqlite() {
|
|
4
|
+
try {
|
|
5
|
+
return await import('node:sqlite');
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
throw new Error(`SQLite databases need Node.js ${SQLITE_MIN_NODE} or newer (this is ${process.version}). Upgrade Node.js, or use Postgres, MySQL or MongoDB.`);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { withPlanLockSync } from '../core/lock-manager.js';
|
|
3
3
|
import { recordCircuitBreakerTrip } from '../core/telemetry.js';
|
|
4
|
+
import { notifyHuman } from '../core/notify.js';
|
|
4
5
|
import { appendEscalation, loadLedger, mutateLedger } from './store.js';
|
|
5
6
|
const MAX_FAILURE_THRESHOLD = 3;
|
|
6
7
|
const newEntry = () => ({ consecutiveFailures: 0, lastFailureTime: '', patchCount: 0 });
|
|
@@ -38,6 +39,15 @@ function tryMutateLedger(targetDir, mutator) {
|
|
|
38
39
|
// Non-fatal
|
|
39
40
|
}
|
|
40
41
|
}
|
|
42
|
+
/** Fire-and-forget: the verdict is synchronous, and an open request keeps a CLI process alive until it settles. */
|
|
43
|
+
function notifyTrip(targetDir, taskId, escalationId, ruleId) {
|
|
44
|
+
notifyHuman(targetDir, {
|
|
45
|
+
event: 'circuit_breaker_tripped',
|
|
46
|
+
taskId,
|
|
47
|
+
escalationId,
|
|
48
|
+
summary: `Task ${taskId} failed ${MAX_FAILURE_THRESHOLD} times in a row (${ruleId}) and is now blocked`,
|
|
49
|
+
}).catch(() => { });
|
|
50
|
+
}
|
|
41
51
|
export class CircuitBreaker {
|
|
42
52
|
static getStatus(targetDir, taskId) {
|
|
43
53
|
const ledger = loadLedger(targetDir);
|
|
@@ -138,6 +148,7 @@ export class CircuitBreaker {
|
|
|
138
148
|
}));
|
|
139
149
|
this.blockTask(targetDir, taskId, `Circuit breaker tripped by Governor: ${ruleResult.ruleId} (${escalationId ?? 'escalation not recorded'})`);
|
|
140
150
|
recordCircuitBreakerTrip(targetDir, taskId).catch(() => { });
|
|
151
|
+
notifyTrip(targetDir, taskId, escalationId ?? undefined, ruleResult.ruleId);
|
|
141
152
|
return { state, escalationId: escalationId ?? undefined, proposal };
|
|
142
153
|
}
|
|
143
154
|
/** Atomic increment under the ledger lock: concurrent failures are all counted. */
|
|
@@ -192,6 +203,7 @@ export class CircuitBreaker {
|
|
|
192
203
|
// 2. Mark task as blocked in .ai/master_plan.json
|
|
193
204
|
this.blockTask(targetDir, taskId, `Circuit breaker tripped by Governor: ${ruleResult.ruleId} (${escalationId})`);
|
|
194
205
|
recordCircuitBreakerTrip(targetDir, taskId).catch(() => { });
|
|
206
|
+
notifyTrip(targetDir, taskId, escalationId === 'unrecorded' ? undefined : escalationId, ruleResult.ruleId);
|
|
195
207
|
return {
|
|
196
208
|
escalationId,
|
|
197
209
|
taskId,
|
|
@@ -3,6 +3,7 @@ export declare function getContractHash(filePath: string): string | null;
|
|
|
3
3
|
export declare function appendAuditLog(targetDir: string, entry: AuditLogEntry): void;
|
|
4
4
|
export declare function applyDbPatch(schema: any, patch: ContractPatch): any;
|
|
5
5
|
export declare function applyApiPatch(contracts: any, patch: ContractPatch): any;
|
|
6
|
+
/** False when the write failed; the shared helper retries Windows sharing violations before giving up. */
|
|
6
7
|
export declare function atomicWriteJson(filePath: string, data: any): boolean;
|
|
7
8
|
export declare function contractFileFor(target: ContractPatch['target']): string;
|
|
8
9
|
export declare function applyPatch(contract: any, patch: ContractPatch): any;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
|
+
import { writeJsonAtomicSync } from '../core/lock-manager.js';
|
|
4
5
|
export function getContractHash(filePath) {
|
|
5
6
|
if (!fs.existsSync(filePath))
|
|
6
7
|
return null;
|
|
@@ -98,18 +99,13 @@ export function applyApiPatch(contracts, patch) {
|
|
|
98
99
|
contracts.updatedAt = new Date().toISOString();
|
|
99
100
|
return contracts;
|
|
100
101
|
}
|
|
102
|
+
/** False when the write failed; the shared helper retries Windows sharing violations before giving up. */
|
|
101
103
|
export function atomicWriteJson(filePath, data) {
|
|
102
|
-
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
103
104
|
try {
|
|
104
|
-
|
|
105
|
-
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
106
|
-
fs.renameSync(tmpPath, filePath);
|
|
105
|
+
writeJsonAtomicSync(filePath, data);
|
|
107
106
|
return true;
|
|
108
107
|
}
|
|
109
108
|
catch {
|
|
110
|
-
if (fs.existsSync(tmpPath)) {
|
|
111
|
-
fs.rmSync(tmpPath, { force: true });
|
|
112
|
-
}
|
|
113
109
|
return false;
|
|
114
110
|
}
|
|
115
111
|
}
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import { Command } from 'commander';
|
|
|
2
2
|
import { runInit } from './commands/init.js';
|
|
3
3
|
import { runStatus } from './commands/status.js';
|
|
4
4
|
import { runValidate } from './commands/validate.js';
|
|
5
|
-
import { runTaskList, runTaskNext, runTaskStart, runTaskComplete, runTaskBlock, runTaskEscalate, runTaskAdd, runTaskProposePatch, runTaskUnlock, } from './commands/task.js';
|
|
5
|
+
import { runTaskList, runTaskNext, runTaskStart, runTaskComplete, runTaskBlock, runTaskReclaim, runTaskEscalate, runTaskAdd, runTaskProposePatch, runTaskUnlock, } from './commands/task.js';
|
|
6
6
|
import { runHookCheck, runHookStatus } from './commands/hook.js';
|
|
7
7
|
import { runSetup } from './commands/setup.js';
|
|
8
8
|
import { runDoctor } from './commands/doctor.js';
|
|
@@ -15,6 +15,7 @@ import { runVerify } from './commands/verify.js';
|
|
|
15
15
|
import { runTestGen, TEST_GEN_FRAMEWORK_HELP } from './commands/test-gen.js';
|
|
16
16
|
import { runBench } from './commands/bench.js';
|
|
17
17
|
import { runTriage } from './commands/triage.js';
|
|
18
|
+
import { runNotifyTest } from './commands/notify.js';
|
|
18
19
|
import { runLearnPropose, runLearnDecide, runLearnList } from './commands/learn.js';
|
|
19
20
|
import { runProfileCommand } from './commands/profile.js';
|
|
20
21
|
import { packageVersion } from './core/version.js';
|
|
@@ -80,7 +81,8 @@ export function createProgram() {
|
|
|
80
81
|
.option('-s, --status <status>', 'Filter tasks by status (pending, in_progress, completed, blocked)')
|
|
81
82
|
.option('-m, --milestone <id>', 'Filter tasks by milestone ID or name')
|
|
82
83
|
.option('--fast-path', 'Show only fast-path tasks')
|
|
83
|
-
.option('--json', 'Output filtered tasks as JSON')
|
|
84
|
+
.option('--json', 'Output filtered tasks as JSON (a summary per task)')
|
|
85
|
+
.option('--full', 'With --json, include every task field')
|
|
84
86
|
.action(async (targetDir, options) => {
|
|
85
87
|
await runTaskList(targetDir, options);
|
|
86
88
|
});
|
|
@@ -121,7 +123,8 @@ export function createProgram() {
|
|
|
121
123
|
.option('-s, --status <status>', 'Filter tasks by status (pending, in_progress, completed, blocked)')
|
|
122
124
|
.option('-m, --milestone <id>', 'Filter tasks by milestone ID or name')
|
|
123
125
|
.option('--fast-path', 'Show only fast-path tasks')
|
|
124
|
-
.option('--json', 'Output filtered tasks as JSON')
|
|
126
|
+
.option('--json', 'Output filtered tasks as JSON (a summary per task)')
|
|
127
|
+
.option('--full', 'With --json, include every task field')
|
|
125
128
|
.action(async (targetDir, options) => {
|
|
126
129
|
await runTaskList(targetDir, options);
|
|
127
130
|
});
|
|
@@ -151,9 +154,11 @@ export function createProgram() {
|
|
|
151
154
|
});
|
|
152
155
|
task
|
|
153
156
|
.command('start <taskId> [targetDir]')
|
|
154
|
-
.description('
|
|
155
|
-
.
|
|
156
|
-
|
|
157
|
+
.description('Claim a pending task whose dependencies are done and mark it in_progress')
|
|
158
|
+
.option('--agent <id>', 'Who claims the task (default: $NATIV_AGENT_ID)')
|
|
159
|
+
.option('--force', 'Human override: start a blocked task or one with unfinished dependencies')
|
|
160
|
+
.action(async (taskId, targetDir, options) => {
|
|
161
|
+
await runTaskStart(taskId, targetDir, { agent: options.agent, force: options.force });
|
|
157
162
|
});
|
|
158
163
|
task
|
|
159
164
|
.command('complete <taskId> [targetDir]')
|
|
@@ -178,6 +183,16 @@ export function createProgram() {
|
|
|
178
183
|
.action(async (taskId, targetDir, options) => {
|
|
179
184
|
await runVerify(taskId, targetDir, options);
|
|
180
185
|
});
|
|
186
|
+
task
|
|
187
|
+
.command('reclaim [taskId] [targetDir]')
|
|
188
|
+
.description('Put in_progress tasks whose agent stopped back to pending (claims older than --older-than hours)')
|
|
189
|
+
.option('--older-than <hours>', 'Age at which a claim counts as abandoned (default 4)', parseFloat)
|
|
190
|
+
.option('--dry-run', 'List what would be reclaimed without changing the plan')
|
|
191
|
+
.option('--force', 'Human override: reclaim the named task even though its claim is fresh')
|
|
192
|
+
.option('--json', 'Output the result as JSON')
|
|
193
|
+
.action(async (taskId, targetDir, options) => {
|
|
194
|
+
await runTaskReclaim(taskId, targetDir, { olderThan: options.olderThan, dryRun: options.dryRun, force: options.force, json: options.json });
|
|
195
|
+
});
|
|
181
196
|
task
|
|
182
197
|
.command('block <taskId> [targetDir]')
|
|
183
198
|
.description('Mark a task as blocked with a required reason')
|
|
@@ -396,6 +411,14 @@ export function createProgram() {
|
|
|
396
411
|
.action(async (escalationId, targetDir, options) => {
|
|
397
412
|
await runTriage(escalationId, targetDir, options);
|
|
398
413
|
});
|
|
414
|
+
program
|
|
415
|
+
.command('notify')
|
|
416
|
+
.description('Human notifications (webhook or command) for decisions and circuit-breaker trips')
|
|
417
|
+
.command('test [targetDir]')
|
|
418
|
+
.description('Send a sample notification through the configured webhook or command')
|
|
419
|
+
.action(async (targetDir) => {
|
|
420
|
+
await runNotifyTest(targetDir);
|
|
421
|
+
});
|
|
399
422
|
program
|
|
400
423
|
.command('mcp [targetDir]')
|
|
401
424
|
.description('Run the native MCP (Model Context Protocol) server over stdio, exposing nativ tools and .ai/ contract resources')
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
export { captureOutput } from './capture.js';
|
|
3
3
|
export { registerMcpResources, MCP_RESOURCES } from './resources.js';
|
|
4
|
-
export
|
|
4
|
+
export { ESCALATION_TYPES } from '../scanner/types.js';
|
|
5
5
|
/**
|
|
6
6
|
* Native MCP (Model Context Protocol) server for Nativ over stdio.
|
|
7
7
|
*
|
package/dist/mcp/server.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { Writable } from 'node:stream';
|
|
4
5
|
import { z } from 'zod';
|
|
5
|
-
import { runTaskList, runTaskNext, runTaskStart, runTaskComplete, runTaskBlock, runTaskEscalate, runTaskAdd, runTaskProposePatch, } from '../commands/task.js';
|
|
6
|
-
import { SUBAGENT_TYPES } from '../scanner/types.js';
|
|
6
|
+
import { runTaskList, runTaskNext, runTaskStart, runTaskComplete, runTaskBlock, runTaskReclaim, runTaskEscalate, runTaskAdd, runTaskProposePatch, } from '../commands/task.js';
|
|
7
|
+
import { SUBAGENT_TYPES, ESCALATION_TYPES } from '../scanner/types.js';
|
|
7
8
|
import { runInit } from '../commands/init.js';
|
|
9
|
+
import { runTriage } from '../commands/triage.js';
|
|
8
10
|
import { runStatus } from '../commands/status.js';
|
|
9
11
|
import { runDoctor } from '../commands/doctor.js';
|
|
12
|
+
import { runValidate } from '../commands/validate.js';
|
|
10
13
|
import { runWorktreeCreate, runWorktreeList, runWorktreeMerge, runWorktreeRemove, } from '../commands/worktree.js';
|
|
11
14
|
import { runDbStatus, runDbInspect, runDbDiff } from '../commands/db.js';
|
|
12
15
|
import { runVerify } from '../commands/verify.js';
|
|
@@ -19,13 +22,7 @@ import { captureOutput } from './capture.js';
|
|
|
19
22
|
import { registerMcpResources } from './resources.js';
|
|
20
23
|
export { captureOutput } from './capture.js';
|
|
21
24
|
export { registerMcpResources, MCP_RESOURCES } from './resources.js';
|
|
22
|
-
export
|
|
23
|
-
'contract_drift',
|
|
24
|
-
'schema_flaw',
|
|
25
|
-
'missing_credential',
|
|
26
|
-
'dependency_conflict',
|
|
27
|
-
'architectural_ambiguity',
|
|
28
|
-
];
|
|
25
|
+
export { ESCALATION_TYPES } from '../scanner/types.js';
|
|
29
26
|
/**
|
|
30
27
|
* Native MCP (Model Context Protocol) server for Nativ over stdio.
|
|
31
28
|
*
|
|
@@ -46,14 +43,15 @@ export function createMcpServer(targetDirArg) {
|
|
|
46
43
|
inputSchema: {},
|
|
47
44
|
}, () => captureOutput(() => runTaskNext(targetDir, { json: true })));
|
|
48
45
|
registerNativTool('task_list', {
|
|
49
|
-
description: 'List project tasks from .ai/master_plan.json with optional filters (JSON).',
|
|
46
|
+
description: 'List project tasks from .ai/master_plan.json with optional filters (JSON summary; full for every field).',
|
|
50
47
|
inputSchema: {
|
|
51
48
|
available: z.boolean().optional().describe('Only tasks that are unblocked and ready for execution'),
|
|
52
49
|
status: z.enum(['pending', 'in_progress', 'completed', 'blocked']).optional().describe('Filter by task status'),
|
|
53
50
|
milestone: z.string().optional().describe('Filter by milestone ID or name'),
|
|
54
51
|
fastPath: z.boolean().optional().describe('Only fast-path tasks'),
|
|
52
|
+
full: z.boolean().optional().describe('Return every task field; the default is a short summary per task'),
|
|
55
53
|
},
|
|
56
|
-
}, ({ available, status, milestone, fastPath }) => captureOutput(() => runTaskList(targetDir, { available, status, milestone, fastPath, json: true })));
|
|
54
|
+
}, ({ available, status, milestone, fastPath, full }) => captureOutput(() => runTaskList(targetDir, { available, status, milestone, fastPath, json: true, brief: !full })));
|
|
57
55
|
registerNativTool('task_add', {
|
|
58
56
|
description: 'Append a task to .ai/master_plan.json with an auto-incremented ID (JSON). Goes to the given milestone, else the active one; ' +
|
|
59
57
|
'fastPath routes it to the fast-path milestone (created on first use).',
|
|
@@ -84,9 +82,12 @@ export function createMcpServer(targetDirArg) {
|
|
|
84
82
|
json: true,
|
|
85
83
|
})));
|
|
86
84
|
registerNativTool('task_start', {
|
|
87
|
-
description: '
|
|
88
|
-
inputSchema: {
|
|
89
|
-
|
|
85
|
+
description: 'Claim a task and mark it in_progress. Refused when it is blocked, completed, waits on unfinished dependencies, or another agent holds a fresh claim. Calling it again on your own task refreshes the claim.',
|
|
86
|
+
inputSchema: {
|
|
87
|
+
taskId: z.string().min(1).describe('Task ID, e.g. task-12'),
|
|
88
|
+
agent: z.string().optional().describe('Your agent id, so parallel agents do not take the same task'),
|
|
89
|
+
},
|
|
90
|
+
}, ({ taskId, agent }) => captureOutput(() => runTaskStart(taskId, targetDir, { agent })));
|
|
90
91
|
registerNativTool('task_complete', {
|
|
91
92
|
description: "Mark a task as completed (always executes its verificationCommand; verification cannot be skipped over MCP). Advances the milestone when all its tasks are done.",
|
|
92
93
|
inputSchema: {
|
|
@@ -115,6 +116,33 @@ export function createMcpServer(targetDirArg) {
|
|
|
115
116
|
reason: z.string().min(1).describe('Why the task is blocked'),
|
|
116
117
|
},
|
|
117
118
|
}, ({ taskId, reason }) => captureOutput(() => runTaskBlock(taskId, reason, targetDir)));
|
|
119
|
+
registerNativTool('triage', {
|
|
120
|
+
description: 'Self-heal escalations: evaluate pending escalations (or one), apply sandbox-proven additive contract fixes and unblock their tasks. Returns compact JSON; items classified REQUIRE_HUMAN_DECISION carry a humanCard to present to the human.',
|
|
121
|
+
inputSchema: {
|
|
122
|
+
escalationId: z.string().optional().describe('Triage only this escalation, e.g. esc-03; default: every pending one'),
|
|
123
|
+
apply: z.boolean().optional().describe('Write proven safe patches and unblock tasks (default true)'),
|
|
124
|
+
},
|
|
125
|
+
}, ({ escalationId, apply }) => captureOutput(() => runTriage(escalationId, targetDir, {
|
|
126
|
+
all: !escalationId,
|
|
127
|
+
apply: apply ?? true,
|
|
128
|
+
json: true,
|
|
129
|
+
interactive: false,
|
|
130
|
+
// stdout carries the MCP protocol, so triage output is routed into the captured tool result.
|
|
131
|
+
output: new Writable({
|
|
132
|
+
write(chunk, _encoding, done) {
|
|
133
|
+
console.log(String(chunk).replace(/\n$/, ''));
|
|
134
|
+
done();
|
|
135
|
+
},
|
|
136
|
+
}),
|
|
137
|
+
})));
|
|
138
|
+
registerNativTool('task_reclaim', {
|
|
139
|
+
description: 'Put in_progress tasks whose agent stopped (claim older than olderThanHours, default 4) back to pending. Pass taskId to reclaim one task that has a stale or unrecorded claim.',
|
|
140
|
+
inputSchema: {
|
|
141
|
+
taskId: z.string().optional().describe('Reclaim only this task'),
|
|
142
|
+
olderThanHours: z.number().positive().optional().describe('Age at which a claim counts as abandoned'),
|
|
143
|
+
dryRun: z.boolean().optional().describe('Report without changing the plan'),
|
|
144
|
+
},
|
|
145
|
+
}, ({ taskId, olderThanHours, dryRun }) => captureOutput(() => runTaskReclaim(taskId, targetDir, { olderThan: olderThanHours, dryRun, json: true })));
|
|
118
146
|
registerNativTool('task_escalate', {
|
|
119
147
|
description: 'Escalate a contract or architectural blocker to the Architect (writes .ai/escalation.json and blocks the task).',
|
|
120
148
|
inputSchema: {
|
|
@@ -192,6 +220,17 @@ export function createMcpServer(targetDirArg) {
|
|
|
192
220
|
description: 'Summarize execution progress across all milestones and tasks.',
|
|
193
221
|
inputSchema: {},
|
|
194
222
|
}, () => captureOutput(() => runStatus(targetDir)));
|
|
223
|
+
registerNativTool('validate', {
|
|
224
|
+
description: 'Read-only integrity check of the .ai/ contracts, master plan, spec references and role guides. ' +
|
|
225
|
+
'Returns only problems (✖ errors, ⚠ warnings) and the verdict; an error result means a contract is missing or malformed.',
|
|
226
|
+
inputSchema: {},
|
|
227
|
+
}, async () => {
|
|
228
|
+
const result = await captureOutput(() => runValidate(targetDir));
|
|
229
|
+
const text = result.content[0]?.type === 'text' ? result.content[0].text : '';
|
|
230
|
+
const problems = text.split('\n').filter((line) => /[✖⚠]/.test(line) && !/validation failed with errors/i.test(line)).map((line) => line.trim());
|
|
231
|
+
const verdict = result.isError ? 'Validation failed.' : 'All contracts are valid.';
|
|
232
|
+
return { ...result, content: [{ type: 'text', text: [...problems, verdict].join('\n') }] };
|
|
233
|
+
});
|
|
195
234
|
registerNativTool('doctor', {
|
|
196
235
|
description: 'Read-only health check of the Claude Code integration: MCP server, hooks, permissions, agents, enforcement mode and provider availability (JSON). ' +
|
|
197
236
|
'Reports drift but never repairs it; repairing is `nativ doctor --fix` in a terminal.',
|
|
@@ -68,6 +68,9 @@ export function createClaudeApiProvider(deps = {}) {
|
|
|
68
68
|
throw new ProviderError('NO_CREDENTIALS', 'The Anthropic API key was rejected.');
|
|
69
69
|
if (/timeout|timed out/i.test(name + message))
|
|
70
70
|
throw new ProviderError('TIMEOUT', `Claude did not answer within ${timeout}ms.`);
|
|
71
|
+
if ((status !== undefined && status >= 500) || /APIConnectionError|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|socket hang up|fetch failed/i.test(name + message)) {
|
|
72
|
+
throw new ProviderError('TRANSIENT', message.slice(0, 300));
|
|
73
|
+
}
|
|
71
74
|
throw new ProviderError('BAD_RESPONSE', message.slice(0, 300));
|
|
72
75
|
}
|
|
73
76
|
},
|
package/dist/providers/gemini.js
CHANGED
|
@@ -52,7 +52,8 @@ export function createGeminiProvider(deps = {}) {
|
|
|
52
52
|
const name = err?.name ?? '';
|
|
53
53
|
if (name === 'TimeoutError' || name === 'AbortError')
|
|
54
54
|
throw new ProviderError('TIMEOUT', `Gemini did not answer within ${timeout}ms.`);
|
|
55
|
-
|
|
55
|
+
// fetch only throws here when the request never got an answer: DNS, refused or reset connection.
|
|
56
|
+
throw new ProviderError('TRANSIENT', err instanceof Error ? err.message : String(err));
|
|
56
57
|
}
|
|
57
58
|
if (res.status === 429) {
|
|
58
59
|
const seconds = Number(res.headers.get('retry-after'));
|
|
@@ -60,6 +61,8 @@ export function createGeminiProvider(deps = {}) {
|
|
|
60
61
|
}
|
|
61
62
|
if (res.status === 401 || res.status === 403)
|
|
62
63
|
throw new ProviderError('NO_CREDENTIALS', 'The Gemini API key was rejected.');
|
|
64
|
+
if (res.status >= 500)
|
|
65
|
+
throw new ProviderError('TRANSIENT', `Gemini responded with HTTP ${res.status}.`);
|
|
63
66
|
if (!res.ok)
|
|
64
67
|
throw new ProviderError('BAD_RESPONSE', `Gemini responded with HTTP ${res.status}.`);
|
|
65
68
|
let body;
|
|
@@ -31,8 +31,12 @@ export declare function providerCooldownRemaining(root: string, id: ProviderId,
|
|
|
31
31
|
export declare function resolveProviderChain(role: ProviderRole, root: string, options?: RegistryOptions): Provider[];
|
|
32
32
|
export declare function resolveProvider(role: ProviderRole, root: string, options?: RegistryOptions): Provider | null;
|
|
33
33
|
/**
|
|
34
|
-
* Runs the request against each provider in turn. A
|
|
35
|
-
*
|
|
34
|
+
* Runs the request against each provider in turn. A TRANSIENT failure (network, 5xx) is retried on the same
|
|
35
|
+
* provider up to MAX_TRANSIENT_RETRIES times with jittered backoff. A rate limit puts that provider on cooldown, and
|
|
36
|
+
* any other ProviderError falls through to the next one. Throws NO_CREDENTIALS when nothing is available and the
|
|
36
37
|
* last error when every provider failed, so the caller can drop to its deterministic path.
|
|
37
38
|
*/
|
|
39
|
+
export declare const MAX_TRANSIENT_RETRIES = 2;
|
|
40
|
+
/** 0.5s, then 1s, each plus up to 50% jitter so parallel agents do not retry in lockstep. */
|
|
41
|
+
export declare function transientBackoffMs(attempt: number, random?: () => number): number;
|
|
38
42
|
export declare function completeWithChain(role: ProviderRole, root: string, request: CompleteRequest, options?: RegistryOptions): Promise<ChainResult>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { writeJsonAtomicSync } from '../core/lock-manager.js';
|
|
3
4
|
import { createClaudeApiProvider } from './claude-api.js';
|
|
4
5
|
import { createClaudeCliProvider, DEFAULT_LIMIT_COOLDOWN_MS } from './claude-cli.js';
|
|
5
6
|
import { createGeminiProvider } from './gemini.js';
|
|
@@ -42,11 +43,7 @@ function readCooldowns(root) {
|
|
|
42
43
|
}
|
|
43
44
|
function writeCooldowns(root, cooldownUntil) {
|
|
44
45
|
try {
|
|
45
|
-
|
|
46
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
47
|
-
const tmp = `${file}.${process.pid}.tmp`;
|
|
48
|
-
fs.writeFileSync(tmp, JSON.stringify({ cooldownUntil }, null, 2), 'utf8');
|
|
49
|
-
fs.renameSync(tmp, file);
|
|
46
|
+
writeJsonAtomicSync(path.join(root, STATE_FILE), { cooldownUntil });
|
|
50
47
|
}
|
|
51
48
|
catch {
|
|
52
49
|
// Cooldown is an optimisation; failing to persist it must never fail the call.
|
|
@@ -93,10 +90,17 @@ export function resolveProvider(role, root, options = {}) {
|
|
|
93
90
|
return resolveProviderChain(role, root, options)[0] ?? null;
|
|
94
91
|
}
|
|
95
92
|
/**
|
|
96
|
-
* Runs the request against each provider in turn. A
|
|
97
|
-
*
|
|
93
|
+
* Runs the request against each provider in turn. A TRANSIENT failure (network, 5xx) is retried on the same
|
|
94
|
+
* provider up to MAX_TRANSIENT_RETRIES times with jittered backoff. A rate limit puts that provider on cooldown, and
|
|
95
|
+
* any other ProviderError falls through to the next one. Throws NO_CREDENTIALS when nothing is available and the
|
|
98
96
|
* last error when every provider failed, so the caller can drop to its deterministic path.
|
|
99
97
|
*/
|
|
98
|
+
export const MAX_TRANSIENT_RETRIES = 2;
|
|
99
|
+
/** 0.5s, then 1s, each plus up to 50% jitter so parallel agents do not retry in lockstep. */
|
|
100
|
+
export function transientBackoffMs(attempt, random = Math.random) {
|
|
101
|
+
const base = 500 * 2 ** attempt;
|
|
102
|
+
return Math.round(base + base * 0.5 * random());
|
|
103
|
+
}
|
|
100
104
|
export async function completeWithChain(role, root, request, options = {}) {
|
|
101
105
|
const chain = resolveProviderChain(role, root, options);
|
|
102
106
|
if (!chain.length)
|
|
@@ -104,17 +108,25 @@ export async function completeWithChain(role, root, request, options = {}) {
|
|
|
104
108
|
const models = (options.config ?? loadProviderConfig(root)).models ?? {};
|
|
105
109
|
const now = options.deps?.now ?? Date.now;
|
|
106
110
|
let lastError = null;
|
|
111
|
+
const sleep = options.deps?.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
107
112
|
for (const provider of chain) {
|
|
108
113
|
const model = request.model ?? models[provider.id] ?? provider.defaultModel;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
114
|
+
for (let attempt = 0;; attempt++) {
|
|
115
|
+
try {
|
|
116
|
+
const text = await provider.complete({ ...request, model }, root);
|
|
117
|
+
return { provider: provider.id, model, text };
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
const failure = err instanceof ProviderError ? err : new ProviderError('BAD_RESPONSE', err instanceof Error ? err.message : String(err));
|
|
121
|
+
lastError = failure;
|
|
122
|
+
if (failure.code === 'TRANSIENT' && attempt < MAX_TRANSIENT_RETRIES) {
|
|
123
|
+
await sleep(transientBackoffMs(attempt));
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (failure.code === 'RATE_LIMITED')
|
|
127
|
+
setProviderCooldown(root, provider.id, failure.retryAfterMs ?? DEFAULT_LIMIT_COOLDOWN_MS, now());
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
118
130
|
}
|
|
119
131
|
}
|
|
120
132
|
throw lastError ?? new ProviderError('BAD_RESPONSE', 'Every provider failed.');
|