@piagent/platform 1.49.1 → 1.50.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 +17 -0
- package/docs/command-reference-vietnamese.md +2 -2
- package/docs/distribution-standard.md +6 -6
- package/docs/operator-manual-vietnamese.md +7 -7
- package/docs/publishing-for-teams.md +1 -0
- package/docs/release-install-policy.md +2 -2
- package/docs/team-onboarding.md +2 -2
- package/docs-site/architecture.html +1 -1
- package/docs-site/assets/search-en.json +1 -1
- package/docs-site/assets/search-vi.json +1 -1
- package/docs-site/benchmark.html +1 -1
- package/docs-site/commands.html +1 -1
- package/docs-site/context-engine.html +1 -1
- package/docs-site/ecosystem.html +1 -1
- package/docs-site/en/architecture.html +1 -1
- package/docs-site/en/benchmark.html +1 -1
- package/docs-site/en/commands.html +1 -1
- package/docs-site/en/context-engine.html +1 -1
- package/docs-site/en/ecosystem.html +1 -1
- package/docs-site/en/index.html +9 -9
- package/docs-site/en/mcp-approval.html +1 -1
- package/docs-site/en/mcp-auth.html +1 -1
- package/docs-site/en/mcp-servers.html +1 -1
- package/docs-site/en/mcp.html +1 -1
- package/docs-site/en/permissions.html +1 -1
- package/docs-site/en/profiles.html +1 -1
- package/docs-site/en/quickstart.html +1 -1
- package/docs-site/en/runtime.html +1 -1
- package/docs-site/en/scope.html +1 -1
- package/docs-site/en/security.html +1 -1
- package/docs-site/en/team.html +1 -1
- package/docs-site/en/update.html +3 -3
- package/docs-site/en/whats-new.html +29 -14
- package/docs-site/en/windows.html +1 -1
- package/docs-site/en/workflows.html +1 -1
- package/docs-site/index.html +9 -9
- package/docs-site/mcp-approval.html +1 -1
- package/docs-site/mcp-auth.html +1 -1
- package/docs-site/mcp-servers.html +1 -1
- package/docs-site/mcp.html +1 -1
- package/docs-site/permissions.html +1 -1
- package/docs-site/profiles.html +1 -1
- package/docs-site/quickstart.html +1 -1
- package/docs-site/releases.json +51 -0
- package/docs-site/runtime.html +1 -1
- package/docs-site/scope.html +1 -1
- package/docs-site/security.html +1 -1
- package/docs-site/team.html +1 -1
- package/docs-site/update.html +3 -3
- package/docs-site/whats-new.html +29 -14
- package/docs-site/windows.html +1 -1
- package/docs-site/workflows.html +1 -1
- package/evals/private-holdout-v1/public-exposure.v2.json +1 -1
- package/package.json +1 -1
- package/packages/piagent-core/managed/approval-refusal.mjs +9 -0
- package/packages/piagent-core/managed/check-evidence.mjs +134 -0
- package/packages/piagent-core/managed/check-questions.mjs +32 -14
- package/packages/piagent-core/managed/command-tools.mjs +7 -3
- package/packages/piagent-core/managed/helper-roles.mjs +1 -1
- package/packages/piagent-core/managed/helper-run.mjs +2 -1
- package/packages/piagent-core/managed/ledger.mjs +5 -5
- package/packages/piagent-core/managed/memory-lessons.mjs +2 -2
- package/packages/piagent-core/managed/repository-operation.mjs +2 -1
- package/packages/piagent-core/managed/tool-boundary.mjs +1 -1
- package/packages/piagent-core/managed/tool-failures.mjs +1 -1
- package/packages/piagent-core/managed/workflow.mjs +11 -8
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-core/runtime/inspection/approval-broker.ts +2 -2
package/package.json
CHANGED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Why an approval did not allow the action, in words the agent can act on: a card
|
|
2
|
+
// nobody answered in time is not a refusal, and asking again is then the right move.
|
|
3
|
+
export function approvalRefusal(decision) {
|
|
4
|
+
const receipt = decision?.receipt;
|
|
5
|
+
if (receipt?.state === 'expired') return Error('managed-operation-expired: the approval card ran out of time with no answer. Nobody refused it and no policy blocked it; the command did not run. Tell the member it is waiting for them, or send the same command again when they are back.');
|
|
6
|
+
if (receipt?.state === 'cancelled') return Error('managed-operation-cancelled: the approval was cancelled because the runtime restarted or stopped the task; nobody refused it and the command did not run.');
|
|
7
|
+
if (receipt?.decision === 'deny') return Error('managed-operation-denied: the member refused this exact command on the approval card; it did not run. Do not send it again unless they say so.');
|
|
8
|
+
return Error('managed-operation-denied: the command was not approved and did not run.');
|
|
9
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { readPatchDigest } from './patch-snapshot.mjs';
|
|
3
|
+
import { itemText, undecided } from './ledger.mjs';
|
|
4
|
+
import { changeLane, lanePolicy, patchStats } from './risk-lanes.mjs';
|
|
5
|
+
|
|
6
|
+
// What passed on which code, repository by repository: a folder can hold several,
|
|
7
|
+
// a later small run must not erase a whole-suite pass, and a pass in one repository
|
|
8
|
+
// says nothing about a push of another. Also what a push needs first: its review.
|
|
9
|
+
|
|
10
|
+
const FAST_SUITE_MS = 60_000, MAX_PARTS = 20, PUSH_REVIEWS = 2;
|
|
11
|
+
const repositoryNames = self => Object.keys(self.repositoryBases ?? {});
|
|
12
|
+
const plain = word => word.replace(/^["']|["']$/g, '');
|
|
13
|
+
|
|
14
|
+
// The repositories a command goes to, from its `cd` and `git -C`. `known` is false
|
|
15
|
+
// when one is a variable: a loop or a function decides it, not the text.
|
|
16
|
+
export function commandRepositories(self, command) {
|
|
17
|
+
const names = repositoryNames(self);
|
|
18
|
+
if (!names.length) return { repositories: ['.'], known: true };
|
|
19
|
+
const found = new Set(); let dir = self.cwd, known = true;
|
|
20
|
+
const visit = (word, stay) => {
|
|
21
|
+
const target = plain(word);
|
|
22
|
+
if (target.includes('$')) { known = false; return; }
|
|
23
|
+
const next = path.resolve(dir, target), first = path.relative(self.cwd, next).split(path.sep)[0];
|
|
24
|
+
if (names.includes(first)) found.add(first);
|
|
25
|
+
if (!stay) dir = next;
|
|
26
|
+
};
|
|
27
|
+
for (const match of String(command ?? '').matchAll(/(?:^|[\s;&|(])(cd|git\s+-C)\s+("[^"]*"|'[^']*'|[^\s;&|)]+)/g)) visit(match[2], match[1] !== 'cd');
|
|
28
|
+
return { repositories: [...found], known };
|
|
29
|
+
}
|
|
30
|
+
const changedRepositories = self => {
|
|
31
|
+
const names = repositoryNames(self);
|
|
32
|
+
return [...new Set([...(self.ownPaths ?? [])].map(file => String(file).split('/')[0]))].filter(name => names.includes(name));
|
|
33
|
+
};
|
|
34
|
+
// What a push sends: the repositories it names, else the ones this conversation changed.
|
|
35
|
+
export function pushRepositories(self, command) {
|
|
36
|
+
const { repositories, known } = commandRepositories(self, command);
|
|
37
|
+
return known && repositories.length ? repositories : changedRepositories(self);
|
|
38
|
+
}
|
|
39
|
+
// The code of one repository: this conversation's files in it, compared with where it started.
|
|
40
|
+
async function repositoryDigest(self, repository) {
|
|
41
|
+
if (repository === '.') return self.digest();
|
|
42
|
+
const scope = new Set([...(self.ownPaths ?? [])].filter(file => String(file).startsWith(`${repository}/`)));
|
|
43
|
+
try { return await (self.scopedDigest ?? (paths => readPatchDigest(self.boundary, paths, self.base, self.repositoryBases)))(scope); } catch { return null; }
|
|
44
|
+
}
|
|
45
|
+
// Reading a digest starts a sandboxed worker: one question reads each once.
|
|
46
|
+
export function digestsOnce(self) {
|
|
47
|
+
const known = new Map();
|
|
48
|
+
return repository => { if (!known.has(repository)) known.set(repository, repositoryDigest(self, repository)); return known.get(repository); };
|
|
49
|
+
}
|
|
50
|
+
const entryOf = (run, repository) => { const passes = run.passes ??= new Map(); if (!passes.has(repository)) passes.set(repository, { suite: null, parts: [] }); return passes.get(repository); };
|
|
51
|
+
|
|
52
|
+
// One test command ended. `part` names its test step, for how long that step takes.
|
|
53
|
+
export async function recordCheck(run, self, command, scope, ok, part) {
|
|
54
|
+
if (!self || !scope) return;
|
|
55
|
+
const started = run.checkStarts?.get(command), took = started ? Date.now() - started : null;
|
|
56
|
+
run.checkStarts?.delete(command);
|
|
57
|
+
for (const repository of commandRepositories(self, command).repositories) {
|
|
58
|
+
const digest = await repositoryDigest(self, repository), entry = entryOf(run, repository);
|
|
59
|
+
if (scope === 'suite') {
|
|
60
|
+
entry.suite = ok ? { digest, command } : null;
|
|
61
|
+
if (ok) run.fullFor?.delete(repository);
|
|
62
|
+
if (ok && took !== null) (self.checkTimes ??= new Map()).set(`${repository}\u0000${part}`, took);
|
|
63
|
+
} else if (ok) entry.parts = [...entry.parts, { digest, command }].slice(-MAX_PARTS);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export function markCheckStart(run, command) { (run.checkStarts ??= new Map()).set(command, Date.now()); }
|
|
67
|
+
|
|
68
|
+
// A whole suite this conversation has seen pass quickly is not worth a detour.
|
|
69
|
+
export function knownFast(self, command, part) {
|
|
70
|
+
const times = self.checkTimes, { repositories } = commandRepositories(self, command);
|
|
71
|
+
return !!times && repositories.length > 0 && repositories.every(repository => times.get(`${repository}\u0000${part}`) < FAST_SUITE_MS);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The repositories whose whole suite has not passed on their current code.
|
|
75
|
+
export async function missingSuites(digestOf, run, repositories) {
|
|
76
|
+
const missing = [];
|
|
77
|
+
for (const repository of repositories) {
|
|
78
|
+
const digest = await digestOf(repository), pass = run.passes?.get(repository)?.suite;
|
|
79
|
+
if (!(pass && digest !== null && pass.digest === digest)) missing.push(repository);
|
|
80
|
+
}
|
|
81
|
+
return missing;
|
|
82
|
+
}
|
|
83
|
+
const minutes = ms => Math.max(1, Math.round(ms / 60_000));
|
|
84
|
+
// The member decides from this: what ran, what did not, what changed, how long the rest takes.
|
|
85
|
+
export async function pushQuestion(self, digestOf, run, repositories, missing) {
|
|
86
|
+
const name = repository => repository === '.' ? 'repo này' : repository;
|
|
87
|
+
const lines = [`Sắp push${repositories.length && repositories[0] !== '.' ? ` ${repositories.join(', ')}` : ' code'} nhưng toàn bộ test chưa pass trên code này.`];
|
|
88
|
+
const passed = repositories.filter(repository => !missing.includes(repository));
|
|
89
|
+
if (passed.length) lines.push(`Đã pass toàn bộ: ${passed.map(name).join(', ')}.`);
|
|
90
|
+
const partly = [], none = [];
|
|
91
|
+
for (const repository of missing) {
|
|
92
|
+
const digest = await digestOf(repository), count = (run.passes?.get(repository)?.parts ?? []).filter(p => digest !== null && p.digest === digest).length;
|
|
93
|
+
if (count) partly.push(`${name(repository)} (${count} lệnh)`); else none.push(name(repository));
|
|
94
|
+
}
|
|
95
|
+
if (partly.length) lines.push(`Mới pass một phần: ${partly.join(', ')}.`);
|
|
96
|
+
if (none.length) lines.push(`Chưa có test nào pass trên code hiện tại: ${none.join(', ')}.`);
|
|
97
|
+
const files = self.ownPaths?.size ?? 0;
|
|
98
|
+
if (files) lines.push(`Phiên này đổi ${files} file.`);
|
|
99
|
+
const took = [...(self.checkTimes ?? new Map())].filter(([key]) => missing.includes(key.split('\u0000')[0])).map(([, ms]) => ms);
|
|
100
|
+
if (took.length) lines.push(`Lần chạy toàn bộ gần nhất mất khoảng ${minutes(Math.max(...took))} phút.`);
|
|
101
|
+
return lines.join('\n');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The checks worth naming: those of the repositories this conversation changed,
|
|
105
|
+
// not of every repository that happens to sit in the folder.
|
|
106
|
+
export function relevantChecks(self, commands) {
|
|
107
|
+
const changed = new Set([...(self.ownPaths ?? [])].map(file => String(file).split('/')[0]));
|
|
108
|
+
if (!changed.size) return commands;
|
|
109
|
+
const kept = commands.filter(command => { const dir = String(command).match(/^cd '([^']+)'/)?.[1]; return !dir || changed.has(dir.split('/')[0]); });
|
|
110
|
+
return kept.length ? kept : commands;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Before a push under a required review: a finding is cheap now and costs a release
|
|
114
|
+
// after. The patch the turn's end would review is the same, so it is reviewed once.
|
|
115
|
+
export async function reviewBeforePush(self, digestOf, run, signal) {
|
|
116
|
+
if (!self.delegate || !self.patchDigest || run.reviewSettled) return;
|
|
117
|
+
const code = await digestOf('.');
|
|
118
|
+
if (run.startDigest === null || code === null || code === run.startDigest) return;
|
|
119
|
+
let policy = run.policy;
|
|
120
|
+
if (policy.lanes) { let stats = null; try { stats = patchStats(await self.patchSnapshot?.()); } catch { /* unreadable: standard */ } policy = lanePolicy(policy, changeLane(stats, run)); }
|
|
121
|
+
if (policy.review !== 'require') return;
|
|
122
|
+
let patch = null; try { patch = await self.patchDigest(); } catch { return; }
|
|
123
|
+
const fresh = !!self.review && !self.review.stale && self.review.patchDigest === patch;
|
|
124
|
+
if (!fresh) {
|
|
125
|
+
if ((run.pushReviews ?? 0) >= PUSH_REVIEWS) return;
|
|
126
|
+
run.pushReviews = (run.pushReviews ?? 0) + 1;
|
|
127
|
+
self.session?.sessionManager?.appendCustomEntry?.('agent-watch-review-start', {});
|
|
128
|
+
// A review that cannot run does not hold the push: the turn's status says so.
|
|
129
|
+
try { await self.delegate({ role: 'review', task: "Review the current patch against the member's request. The main agent is about to push it: report what must change before it leaves this machine." }, signal, { harness: true }); }
|
|
130
|
+
catch (error) { if (signal?.aborted) throw error; return; }
|
|
131
|
+
}
|
|
132
|
+
const pending = undecided(run).filter(item => item.source === 'review');
|
|
133
|
+
if (pending.length) throw Error(`managed-review-before-push: this command did not run. The review of the code you are pushing has ${pending.length} finding(s) with no decision:\n${pending.map(item => '- ' + itemText(item)).join('\n')}\nFix each, or reject it with resolve_objection (what you checked and why), then push again.`);
|
|
134
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { memberQuestions } from './member-questions.mjs';
|
|
3
3
|
import { effectivePermission } from './permission.mjs';
|
|
4
|
+
import { digestsOnce, knownFast, markCheckStart, missingSuites, pushQuestion, pushRepositories, reviewBeforePush } from './check-evidence.mjs';
|
|
4
5
|
|
|
5
6
|
// How much to test is the member's decision: a one-line fix should not cost a
|
|
6
7
|
// thirty-minute suite. The harness asks at the end of a change, before a whole
|
|
@@ -15,19 +16,26 @@ const FILTERED = /(?:^|\s)(?:-k|-t|--grep|--testNamePattern|--test-name-pattern|
|
|
|
15
16
|
const TEST_RUNNER = /\b(npm|pnpm|yarn|bun)\s+(run\s+)?test(:\S+)?(?=\s|$)|\bnode\s+--test\b|\b(vitest|jest|mocha|pytest|rspec|phpunit)\b|\bplaywright\s+test\b|\bpython3?\s+-m\s+(pytest|unittest)\b|\b(go|cargo|swift|dotnet|deno)\s+test\b|\bmake\s+test\b|\bxcodebuild\b.*\btest\b|\b(gradle|gradlew|mvn|mvnw)\b.*\b(test|verify)\b/;
|
|
16
17
|
// Test commands start a command: `npm install -D vitest` or `rm jest.config.ts` only name one.
|
|
17
18
|
const TEST_START = new RegExp(`^(?:${TEST_RUNNER.source})`);
|
|
18
|
-
|
|
19
|
-
const
|
|
19
|
+
// A function body, a group or a loop body opens a part as well.
|
|
20
|
+
const LAUNCHER = /^(?:[\w-]+\s*\(\)\s*\{?\s*|\{\s+|\(\s*|(?:do|then|else)\s+|\w+=\S*\s+|time\s+|nohup\s+|timeout\s+\S+\s+|npx\s+(?:-\S+\s+)*|bunx\s+|pnpm\s+(?:exec|dlx)\s+|uv\s+run\s+|poetry\s+run\s+|yarn\s+(?=jest|vitest|mocha|playwright)|node_modules\/\.bin\/|\.\/)+/;
|
|
21
|
+
const partsOf = bare => bare.split(/\s*(?:\|\|?|&&|;)\s*/).map(part => part.replace(/\s\d*>.*$/, '').replace(/[\s)}]+$/, '').trim());
|
|
20
22
|
const NOT_TESTS = /\b(lint|typecheck|type-check|tsc|eslint|biome|ruff|mypy|pyright|vet|clippy|format|build)\b/;
|
|
21
23
|
const TARGET_FLAG = /^(-p|--package|-w|--workspace|--tests)$|^(-Dtest=|-only-testing:|--workspace=|--package=)/;
|
|
24
|
+
// `go test -p 4` sets how many packages run at once; for cargo -p names a package.
|
|
25
|
+
const GO_TEST = /^go\s+test\b/;
|
|
22
26
|
const VALUE_FLAG = /^(-c|--config|--reporter|--project|--workers|--timeout|--rootDir|--root|-f|--file|-o|--output)$/;
|
|
23
27
|
// A file or folder named on the command line; a flag's own value names none.
|
|
24
|
-
const targetsPart = args => args.some((a, i) => {
|
|
28
|
+
const targetsPart = (args, go = false) => args.some((a, i) => {
|
|
29
|
+
// A variable: a function's arguments, a loop's file, $(go list …). The caller chose them, so this is not the whole command.
|
|
30
|
+
if (a.startsWith('$')) return true;
|
|
31
|
+
if (go && a === '-p') return false;
|
|
25
32
|
if (TARGET_FLAG.test(a)) return true;
|
|
33
|
+
if (go && args[i - 1] === '-p') return false;
|
|
26
34
|
if (a.startsWith('-') || VALUE_FLAG.test(args[i - 1] ?? '') || a.includes('*') || a.endsWith('...')) return false;
|
|
27
35
|
return a.includes('/') || /\.(test|spec)\.\w+$|_test\.\w+$|^test_\w+\.\w+$/.test(a);
|
|
28
36
|
});
|
|
29
37
|
// What a command runs, without the text it merely quotes (a commit message, an echo).
|
|
30
|
-
const unquoted = text => text.replace(/"[^"]*"|'[^']*'/g, '""');
|
|
38
|
+
const unquoted = text => text.replace(/"[^"]*"|'[^']*'/g, quoted => quoted.startsWith('"$') ? '$' : '""');
|
|
31
39
|
export function checkScope(command, declared = []) {
|
|
32
40
|
const text = String(command ?? '').replace(/\s+/g, ' ').trim();
|
|
33
41
|
if (!text || /^(echo|printf|grep|rg|cat|sed|awk|head|tail|less|git|ls|find)\b/.test(text) || VERSION_ONLY.test(text) || /--collect-only/.test(text)) return null;
|
|
@@ -36,12 +44,17 @@ export function checkScope(command, declared = []) {
|
|
|
36
44
|
const own = part.replace(LAUNCHER, ''), runner = own.match(TEST_START);
|
|
37
45
|
if (!runner) continue;
|
|
38
46
|
if (FILTERED.test(own)) return 'partial';
|
|
39
|
-
return !declared.includes(text) && targetsPart(own.slice(runner[0].length).split(' ').filter(Boolean)) ? 'partial' : 'suite';
|
|
47
|
+
return !declared.includes(text) && targetsPart(own.slice(runner[0].length).split(' ').filter(Boolean), GO_TEST.test(own)) ? 'partial' : 'suite';
|
|
40
48
|
}
|
|
41
49
|
// A script the repository declares: it cannot be narrowed from here.
|
|
42
50
|
const named = declared.find(d => partsOf(bare).some(part => part.startsWith(unquoted(d))));
|
|
43
51
|
return named && !NOT_TESTS.test(named) ? 'suite' : null;
|
|
44
52
|
}
|
|
53
|
+
// The test step of a command, without what surrounds it: the same step costs the same.
|
|
54
|
+
export function suitePart(command) {
|
|
55
|
+
for (const part of partsOf(unquoted(String(command ?? '').replace(/\s+/g, ' ').trim()))) { const own = part.replace(LAUNCHER, ''); if (TEST_START.test(own)) return own; }
|
|
56
|
+
return String(command ?? '').trim();
|
|
57
|
+
}
|
|
45
58
|
// The member's own words asking for the whole suite: nothing to ask them then.
|
|
46
59
|
const RUN_ALL = String.raw`(?:full|all(?:\s+the)?|whole(?:\s+the)?|entire(?:\s+the)?|the\s+(?:full|whole|entire))`;
|
|
47
60
|
const FULL_WORDS = new RegExp([
|
|
@@ -94,18 +107,19 @@ export async function askCheckScope(self, { partPassed = false, signal } = {}) {
|
|
|
94
107
|
|
|
95
108
|
const PARTIAL_REASON = 'managed-check-scope: the member chose to run only the tests that cover the change. Run one file, a filter or one package, not the whole suite. If this project has no way to run part of it, say so and repeat the command: the member is asked.';
|
|
96
109
|
const BYPASS_REASON = 'managed-check-scope: run the tests that cover the change (one file, a filter, one package). A whole-suite run needs the member to ask for it, or their answer before a push. If this project has no way to run part of it, say so and repeat the command.';
|
|
97
|
-
const PUSH_REASON =
|
|
110
|
+
const PUSH_REASON = missing => `managed-check-scope: the member wants the whole suite before this push${missing.length && missing[0] !== '.' ? ` (still needed in: ${missing.join(', ')})` : ''}. Run it, fix what fails, then push.`;
|
|
98
111
|
const STOPPED_REASON = 'managed-check-scope: the member did not answer whether to push now. Do not push; say so and let them decide.';
|
|
99
112
|
const WORDS_REASON = words => `managed-check-scope: this command did not run. The member answered about testing: "${String(words).slice(0, 500)}". Follow that.`;
|
|
100
113
|
|
|
101
114
|
// Before a whole suite or a push: returns to let it run, throws to send the agent another way.
|
|
102
115
|
export async function beforeCheckOrPush(self, command, ctx, signal) {
|
|
103
116
|
const run = self.run;
|
|
104
|
-
if (!run || run.policy?.verify === 'off') return;
|
|
117
|
+
if (!run || (run.policy?.verify === 'off' && !isGitPush(command))) return;
|
|
105
118
|
// Calls sent together wait for one answer: the next one sees it.
|
|
106
119
|
const turn = (run.scopeQuestion ?? Promise.resolve()).then(() => holdOrLet(self, run, command, ctx, signal));
|
|
107
120
|
run.scopeQuestion = turn.catch(() => {});
|
|
108
|
-
|
|
121
|
+
await turn;
|
|
122
|
+
markCheckStart(run, command);
|
|
109
123
|
}
|
|
110
124
|
async function holdOrLet(self, run, command, ctx, signal) {
|
|
111
125
|
const push = isGitPush(command);
|
|
@@ -113,18 +127,22 @@ async function holdOrLet(self, run, command, ctx, signal) {
|
|
|
113
127
|
const surface = surfaceOf(self, ctx);
|
|
114
128
|
if (!surface) return;
|
|
115
129
|
if (push) {
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
130
|
+
const digestOf = digestsOnce(self);
|
|
131
|
+
await reviewBeforePush(self, digestOf, run, signal);
|
|
132
|
+
if (run.policy?.verify === 'off') return;
|
|
133
|
+
const now = await digestOf('.'), repositories = pushRepositories(self, command), single = !repositories.length || repositories[0] === '.';
|
|
134
|
+
const missing = await missingSuites(digestOf, run, repositories.length ? repositories : ['.']);
|
|
135
|
+
if ((single ? run.suitePassedOn(now) : false) || !missing.length || (run.pushAllowedOn !== undefined && run.pushAllowedOn === now)) return;
|
|
136
|
+
const picked = await ask(surface, await pushQuestion(self, digestOf, run, repositories, missing), [PUSH_FULL, PUSH_NOW], signal);
|
|
119
137
|
if (picked === PUSH_NOW) { run.pushAllowedOn = now; return; }
|
|
120
138
|
if (picked?.other) throw Error(WORDS_REASON(picked.other));
|
|
121
139
|
if (picked !== PUSH_FULL) throw Error(STOPPED_REASON);
|
|
122
|
-
run.scopeChoice = 'full';
|
|
123
|
-
throw Error(PUSH_REASON);
|
|
140
|
+
run.scopeChoice = 'full'; run.fullFor = new Set(missing);
|
|
141
|
+
throw Error(PUSH_REASON(missing));
|
|
124
142
|
}
|
|
125
143
|
// Sent another way once; the same command again means the part did not do: the member decides (Bypass: it runs).
|
|
126
144
|
const held = run.scopeHeld ??= new Set(), again = held.has(command); held.add(command);
|
|
127
|
-
if (effectivePermission(self) === 'bypass') { if (again) return; throw Error(BYPASS_REASON); }
|
|
145
|
+
if (effectivePermission(self) === 'bypass') { if (again || knownFast(self, command, suitePart(command))) return; throw Error(BYPASS_REASON); }
|
|
128
146
|
if (run.scopeChoice === 'partial' && !again) throw Error(PARTIAL_REASON);
|
|
129
147
|
const picked = await ask(surface, `Agent muốn chạy toàn bộ test: ${String(command).slice(0, 160)}`, [PART, FULL], signal);
|
|
130
148
|
if (picked === FULL) { run.scopeChoice = 'full'; return; }
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { beforeCheckOrPush } from './check-questions.mjs';
|
|
3
|
+
import { approvalRefusal } from './approval-refusal.mjs';
|
|
3
4
|
import { asksOnlyHandoff, broadAccess, effectivePermission, networkConfirmation } from './permission.mjs';
|
|
4
5
|
import { handoffReason } from '../runtime/policy/handoff-policy.mjs';
|
|
5
6
|
import { engineAlive, engineLauncher } from './container-engine.mjs';
|
|
@@ -38,7 +39,7 @@ const KINDS = {
|
|
|
38
39
|
},
|
|
39
40
|
host: {
|
|
40
41
|
name: 'run_on_host', label: 'Run on this machine', scope: 'host-command-once', provider: 'host', riskClass: 'high',
|
|
41
|
-
description: 'Run one shell command on the member\'s machine, outside the sandbox, as the member, after they approve that exact command (only Bypass runs it without asking, and even there a push, PR, publish, deploy or a command that reaches credentials is asked). Use it when the sandbox blocks what the task needs and no other tool covers it: install a runtime or tool the project needs (brew install openjdk@21, brew install node@22, sdk install java…), git pull with the member\'s sign-in, git push only when the member asked for it, a private registry, a file outside the project. It has the member\'s files, Keychain and sign-ins, so ask only for what the task needs and say why in reason. No input can be typed: commands that need the member\'s password (sudo, a .pkg installer) or an interactive sign-in are refused; ask the member with ask_user for those.',
|
|
42
|
+
description: 'Run one shell command on the member\'s machine, outside the sandbox, as the member, in their login shell (zsh on macOS: an unquoted $var is not split into words, unlike bash), after they approve that exact command (only Bypass runs it without asking, and even there a push, PR, publish, deploy or a command that reaches credentials is asked). Use it when the sandbox blocks what the task needs and no other tool covers it: install a runtime or tool the project needs (brew install openjdk@21, brew install node@22, sdk install java…), git pull with the member\'s sign-in, git push only when the member asked for it, a private registry, a file outside the project. It has the member\'s files, Keychain and sign-ins, so ask only for what the task needs and say why in reason. No input can be typed: commands that need the member\'s password (sudo, a .pkg installer) or an interactive sign-in are refused; ask the member with ask_user for those.',
|
|
42
43
|
allow: 'Run this exact command once on this machine, outside the sandbox, as you: it can read and change your files and use your sign-ins.',
|
|
43
44
|
title: 'Chạy lệnh trên máy (ngoài sandbox)', allowText: 'chạy đúng lệnh này một lần trên máy bạn, ngoài sandbox, bằng quyền của bạn: lệnh đọc/sửa được file và dùng được tài khoản đã đăng nhập trên máy.',
|
|
44
45
|
// Full access does not cover it: the member approves each command (with
|
|
@@ -78,6 +79,7 @@ export function stillAsks(self, reason) {
|
|
|
78
79
|
// member may allow it once, allow the same exact action for the rest of the
|
|
79
80
|
// conversation (never a handoff or a waiver of checks: its scope names it), deny it, or deny it and
|
|
80
81
|
// stop the turn so the agent does not try again.
|
|
82
|
+
const HANDOFF_APPROVAL_MS = 30 * 60_000;
|
|
81
83
|
const CHOICES = { once: 'Đồng ý một lần', session: 'Đồng ý cho cả cuộc trò chuyện (đúng lệnh này)', deny: 'Từ chối', stop: 'Từ chối và dừng lượt này' };
|
|
82
84
|
export async function approve(self, id, signal, ctx, action, { title, body }) {
|
|
83
85
|
const grantable = !/handoff|waiver/.test(String(action.requestedScope ?? '')), key = `${action.toolName}\u0000${action.commandPreview ?? JSON.stringify(action.rawAction ?? {})}`;
|
|
@@ -91,9 +93,11 @@ export async function approve(self, id, signal, ctx, action, { title, body }) {
|
|
|
91
93
|
const picked = await ctx.ui.select(`${title}\n\n${body}`, choices.map(choice => CHOICES[choice]));
|
|
92
94
|
return choices.find(choice => CHOICES[choice] === picked) ?? 'deny';
|
|
93
95
|
} } : {}),
|
|
96
|
+
// A push waits longer than other cards: the member may be away while a suite ran.
|
|
97
|
+
...(/handoff/.test(String(action.requestedScope ?? '')) ? { ttlMs: HANDOFF_APPROVAL_MS } : {}),
|
|
94
98
|
unavailableFallback: 'terminal-confirm', recheck: () => !signal?.aborted && Boolean(self.grant) });
|
|
95
99
|
if (!decision.allowed && decision.choice === 'stop') { try { ctx?.abort?.(); } catch { /* the denial below still stands */ } throw Error('managed-operation-denied-stop: bạn đã từ chối và dừng lượt này; không thử lại, chờ member.'); }
|
|
96
|
-
if (!decision.allowed || !decision.consume() || signal?.aborted) throw
|
|
100
|
+
if (!decision.allowed || !decision.consume() || signal?.aborted) throw approvalRefusal(decision);
|
|
97
101
|
if (decision.choice === 'session' && grantable) (self.sessionApprovals ??= new Set()).add(key);
|
|
98
102
|
}
|
|
99
103
|
|
|
@@ -126,7 +130,7 @@ function commandTool(self, kind) {
|
|
|
126
130
|
} finally {
|
|
127
131
|
// What the command installed (a JDK, Node) is there for the next one.
|
|
128
132
|
if (kind === 'host') self.boundary.refreshEnvironment();
|
|
129
|
-
await self.claimChanges(before); await self.run?.afterTool('bash', args, ok, () => self.digest()); await self.refreshReview();
|
|
133
|
+
await self.claimChanges(before); await self.run?.afterTool('bash', args, ok, () => self.digest(), self); await self.refreshReview();
|
|
130
134
|
}
|
|
131
135
|
} };
|
|
132
136
|
}
|
|
@@ -87,7 +87,7 @@ export function countedCheck(managed, tool) {
|
|
|
87
87
|
try { const result = await tool.execute(...args); ok = !result?.isError; return result; }
|
|
88
88
|
finally {
|
|
89
89
|
const after = counted && before !== null ? await folder() : null;
|
|
90
|
-
if (after !== null && after === before) await managed.run.afterTool('bash', args[1], ok, () => managed.digest());
|
|
90
|
+
if (after !== null && after === before) await managed.run.afterTool('bash', args[1], ok, () => managed.digest(), managed);
|
|
91
91
|
}
|
|
92
92
|
} };
|
|
93
93
|
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// refusal, starts once more: Studio then gives the new helper the next model
|
|
5
5
|
// of its role that serves (an auto role's list).
|
|
6
6
|
import { fenced, HELPER_UNTRUSTED_RULE } from './untrusted.mjs';
|
|
7
|
+
import { relevantChecks } from './check-evidence.mjs';
|
|
7
8
|
import { jobTitle } from './call-purpose.mjs';
|
|
8
9
|
import { ManagedToolBoundary } from './tool-boundary.mjs';
|
|
9
10
|
import { managedResourceLoader } from './resource-loader.mjs';
|
|
@@ -144,7 +145,7 @@ async function answerUnder(managed, key, task, signal, harness, snapshot, parts,
|
|
|
144
145
|
(managed.jobTitles ??= new WeakMap()).set(runtime, jobTitle(task, title));
|
|
145
146
|
const setup = HELPER_SETUP[role], memory = helperMemoryTools(managed, () => grant, renew);
|
|
146
147
|
boundary = new ManagedToolBoundary({ cwd: managed.cwd, sdkRoot: managed.sdk, protectedRoots: managed.protectedRoots, readOnly: true, commands: setup.commands });
|
|
147
|
-
const checks = role === 'verify' ? `\n\nRepository checks: ${managed.checks.commands.length ? managed.checks.commands.map(c => '`' + c + '`').join(', ') : 'none declared or detected; choose the tests, type check or build that cover the claims'}.${managed.run?.fullAsked || managed.run?.scopeChoice === 'full' ? '' : ' Run only the tests that cover the claims: the member decides about a whole-suite run.'}` : '';
|
|
148
|
+
const checks = role === 'verify' ? `\n\nRepository checks: ${managed.checks.commands.length ? relevantChecks(managed, managed.checks.commands).map(c => '`' + c + '`').join(', ') : 'none declared or detected; choose the tests, type check or build that cover the claims'}.${managed.run?.fullAsked || managed.run?.scopeChoice === 'full' ? '' : ' Run only the tests that cover the claims: the member decides about a whole-suite run.'}` : '';
|
|
148
149
|
// One helper session answers the brief (with one part of the patch for a
|
|
149
150
|
// review in parts); its reply and tokens. A helper runs at its role's
|
|
150
151
|
// level from Studio (fixed by the Harness, or its main agent's).
|
|
@@ -120,7 +120,7 @@ export function rejudgeBrief(item) {
|
|
|
120
120
|
// One step of the completion gate (true: the gate goes round again). A
|
|
121
121
|
// rejected premise objection goes back to its helper, which withdraws it or
|
|
122
122
|
// keeps it for the member; then the items the main agent never decided are
|
|
123
|
-
// put to it once, with no new review.
|
|
123
|
+
// put to it once each, with no new review: a later review's findings are new items.
|
|
124
124
|
export async function settleLedger(managed, run, { note, send, signal }) {
|
|
125
125
|
const rejected = toRejudge(run)[0];
|
|
126
126
|
if (rejected) {
|
|
@@ -133,9 +133,9 @@ export async function settleLedger(managed, run, { note, send, signal }) {
|
|
|
133
133
|
await note(`Harness: the ${rejected.role} subagent keeps its objection; the member decides.\n${itemText(rejected)}`, { phase: 'dispute', role: rejected.role, id: rejected.id });
|
|
134
134
|
return true;
|
|
135
135
|
}
|
|
136
|
-
const pending = undecided(run);
|
|
137
|
-
if (!pending.length
|
|
138
|
-
|
|
136
|
+
const asked = run.decidedAsked ??= new Set(), pending = undecided(run).filter(item => !asked.has(item.id));
|
|
137
|
+
if (!pending.length) return false;
|
|
138
|
+
for (const item of pending) asked.add(item.id);
|
|
139
139
|
await send(decideText(pending), { phase: 'answer', ids: pending.map(i => i.id), issues: pending.slice(0, 10).map(i => ({ kind: i.kind, detail: i.detail })) });
|
|
140
140
|
return true;
|
|
141
141
|
}
|
|
@@ -151,7 +151,7 @@ export function ledgerWords(run) {
|
|
|
151
151
|
const items = ledgerOf(run), count = s => items.filter(i => i.status === s).length, words = [];
|
|
152
152
|
if (!items.length && !run.minorFindings) return null;
|
|
153
153
|
for (const [status, label] of [['accepted', 'accepted'], ['rejected', 'rejected'], ['withdrawn', 'withdrawn by the helper'], ['cleared', 'cleared by a later check'], ['disputed', 'for the member'], ['open', 'left without a decision'], ['noted', 'noted']])
|
|
154
|
-
if (count(status)) words.push(`${count(status)} ${label}`);
|
|
154
|
+
if (count(status)) words.push(`${count(status)} ${label}${status === 'open' ? ` (${items.filter(i => i.status === 'open').map(i => i.id).join(', ')})` : ''}`);
|
|
155
155
|
if (run.minorFindings) words.push(`${run.minorFindings} minor finding(s) listed`);
|
|
156
156
|
return `objections: ${words.join(', ')}`;
|
|
157
157
|
}
|
|
@@ -55,7 +55,7 @@ export class CheckLessons {
|
|
|
55
55
|
// shape it becomes a failure lesson for the team memory.
|
|
56
56
|
#toolError(call,event,output) {
|
|
57
57
|
if(!COMMAND_TOOLS.has(call.tool)||call.check||this.lessons.length>=MAX_LESSONS)return;
|
|
58
|
-
if(/managed[-_](?:tool[-_]cancelled|operation[-_]denied|helper[-_]cancelled)|Command aborted|Command timed out/i.test(output))return;
|
|
58
|
+
if(/managed[-_](?:tool[-_]cancelled|operation[-_](?:denied|expired|cancelled)|helper[-_]cancelled)|Command aborted|Command timed out/i.test(output))return;
|
|
59
59
|
const line=output.split('\n').find(l=>ERROR_LINE.test(l));
|
|
60
60
|
if(!line)return;
|
|
61
61
|
const command=commandShape(call.command);
|
|
@@ -85,7 +85,7 @@ export class CheckLessons {
|
|
|
85
85
|
const output=textOf(event.result), c=call.check;
|
|
86
86
|
const exitFailed=event.isError || (event.result?.details?.exitCode !== undefined && event.result.details.exitCode!==0);
|
|
87
87
|
if(exitFailed || (c.piped && failedOutput(output))) {
|
|
88
|
-
if(/managed[-_](?:tool[-_]cancelled|operation[-_]denied|helper[-_]cancelled)|Command aborted|Command timed out/i.test(output))return;
|
|
88
|
+
if(/managed[-_](?:tool[-_]cancelled|operation[-_](?:denied|expired|cancelled)|helper[-_]cancelled)|Command aborted|Command timed out/i.test(output))return;
|
|
89
89
|
this.failed++;this.last='failed';
|
|
90
90
|
const diagnostic=output.split('\n').find(l=>/error|fail|exception|not found|EADDRINUSE/i.test(l)) ?? 'Check exited unsuccessfully';
|
|
91
91
|
if(this.failures.size>=20)this.failures.delete(this.failures.keys().next().value);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { broadAccess } from './permission.mjs';
|
|
2
|
+
import { approvalRefusal } from './approval-refusal.mjs';
|
|
2
3
|
import fs from 'node:fs';
|
|
3
4
|
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
@@ -121,7 +122,7 @@ export function fetchOriginTool(self) {
|
|
|
121
122
|
reason:`Fetch origin branches from ${plan.repository}`,riskClass:'low',allowConsequence:'Download origin branches once; keep the current branch and working files unchanged.',denyConsequence:'No network request or credential read.'},
|
|
122
123
|
terminalConfirm:()=>ctx?.ui?.confirm?.('Tải nhánh từ origin',`Tải các nhánh của ${plan.repository} về ${plan.cwd} một lần.\n\nĐồng ý: chỉ tải nhánh; nhánh hiện tại và file đang làm không đổi.\nTừ chối: không gửi request mạng, không đọc thông tin đăng nhập.`)??Promise.resolve(false),
|
|
123
124
|
unavailableFallback:'terminal-confirm',recheck:()=>!signal?.aborted&&Boolean(self.grant)});
|
|
124
|
-
if(!decision.allowed||!decision.consume()||signal?.aborted)throw
|
|
125
|
+
if(!decision.allowed||!decision.consume()||signal?.aborted)throw approvalRefusal(decision);
|
|
125
126
|
return executeRepositoryFetch(self.boundary,plan,signal);
|
|
126
127
|
}};
|
|
127
128
|
}
|
|
@@ -112,7 +112,7 @@ ${proxyPort && !network ? `(allow mach-lookup (global-name "com.apple.trustd.age
|
|
|
112
112
|
(allow network-outbound (literal ${literal(docker)}))` : ''}` : loopback ? '(allow network-outbound (remote ip "localhost:*"))' : proxyPort ? `(allow network-outbound (remote ip "localhost:${Number(proxyPort)}"))` : ''}
|
|
113
113
|
${denied.map(root => `(deny file-read* file-write* (require-all (subpath ${literal(root)})${skills.filter(dir => inside(dir, root) && dir !== root).map(dir => ` (require-not (subpath ${literal(dir)}))`).join('')}))`).join('\n')}
|
|
114
114
|
(deny file-read* file-read-metadata file-write* (require-all (require-any (regex #"(^|/)(\\.env([./]|$)|credentials([./]|$)|\\.npmrc$|\\.netrc$)") (regex #"/\\.[^/]+/auth\\.json$") ${projectRoots.map(root => `(literal ${literal(path.join(root, 'auth.json'))})`).join(' ')})
|
|
115
|
-
(require-not (regex #"(^|/)\\.env\\.(example|sample|template|dist|defaults)$"))))
|
|
115
|
+
(require-not (regex #"(^|/)\\.env\\.(example|sample|template|dist|defaults)$")))) (allow file-read* file-read-metadata file-write* (require-all (subpath ${literal(temporary)}) (regex #"/\\.env([.][^/]*)?$")))
|
|
116
116
|
`;
|
|
117
117
|
}
|
|
118
118
|
// Toolchains from the pinned node/git and Homebrew, compilers and SDK; the
|
|
@@ -15,7 +15,7 @@ const INPUT_CHARS = 500, ERROR_HEAD = 1000, ERROR_TAIL = 2000;
|
|
|
15
15
|
// One request carries at most BATCH failures; KEEP wait for the next one.
|
|
16
16
|
const BATCH = 20, KEEP = 100, ATTEMPTS = 3;
|
|
17
17
|
// The member stopped the tool or declined to approve it: not a failure.
|
|
18
|
-
const NOT_FAILURES = new Set(['managed_tool_cancelled', 'managed_helper_cancelled', 'managed_operation_denied']);
|
|
18
|
+
const NOT_FAILURES = new Set(['managed_tool_cancelled', 'managed_helper_cancelled', 'managed_operation_denied', 'managed_operation_expired', 'managed_operation_cancelled']);
|
|
19
19
|
|
|
20
20
|
const text = result => typeof result === 'string' ? result
|
|
21
21
|
: Array.isArray(result?.content) ? result.content.filter(part => part?.type === 'text').map(part => part.text ?? '').join('\n') : '';
|
|
@@ -4,7 +4,8 @@ import { TsDiagnostics } from './ts-diagnostics.mjs';
|
|
|
4
4
|
import { checkpointsOf } from './checkpoints.mjs';
|
|
5
5
|
import { declaredChecks, detectedChecks, repositoryChecks, isCheckCommand, isReadOnlyCommand, nonCodePath, onlyNonCode } from './repository-checks.mjs';
|
|
6
6
|
export { declaredChecks, detectedChecks, repositoryChecks, isCheckCommand, isReadOnlyCommand, nonCodePath, onlyNonCode };
|
|
7
|
-
import { askCheckScope, asksFullChecks, beforeCheckOrPush, checkScope } from './check-questions.mjs';
|
|
7
|
+
import { askCheckScope, asksFullChecks, beforeCheckOrPush, checkScope, suitePart } from './check-questions.mjs';
|
|
8
|
+
import { recordCheck, relevantChecks } from './check-evidence.mjs';
|
|
8
9
|
import { LANE_LIMITS, patchStats, changeLane, lanePolicy } from './risk-lanes.mjs';
|
|
9
10
|
import { briefCounts, decisionsText, ledgerGuard, ledgerOf, ledgerWords, reviewFocus, saveLedger, setStatus, settleLedger } from './ledger.mjs';
|
|
10
11
|
|
|
@@ -81,7 +82,7 @@ export function workflowPrompt(policy, checks) {
|
|
|
81
82
|
const L = LANE_LIMITS;
|
|
82
83
|
lines.push(`- Size: the process follows the size of your change. A small change (at most ${L.tinyFiles} files and ${L.tinyLines} changed lines, no migration, auth, security, CI, dependency or AGENTS.md file) ends after its checks, without a review: do not ask for one. A risky change (a complex request, one of those files, more than ${L.riskyFiles} files or ${L.riskyLines} lines) must pass the checks${policy.review !== 'off' ? ' and the harness review' : ''} even where they are only suggested.`);
|
|
83
84
|
}
|
|
84
|
-
return lines.length ? `\n\nProcess for code changes (company Harness):\n${lines.join('\n')}\n${process.platform === 'darwin' ? 'End-to-end and UI checks run in bash with the project\'s Playwright (@playwright/test; add it with npm when the project lacks it) and the browsers installed on this Mac, against a server the test or start_process starts; while a local proxy listens they need run_with_network. When Playwright\'s browsers are missing, ask with run_on_host to run `npx playwright install chromium`.' : 'End-to-end tests that start a local server and a browser run through run_with_network, which the user approves.'} If a check cannot run because the machine lacks something (a runtime such as a JDK, Docker, a database or another service, the network), do not stop there: request the approval that makes it run (run_with_network, run_with_docker, start_docker, or run_on_host to install what is missing) or ask the member with ask_user; never install or start anything without that approval.\nA command stops after 10 minutes and anything left running in the background stops when the turn ends: run the checks that cover your change first and a long suite in parts, not in the background.${process.platform === 'darwin' ? ' A dev server, watcher or local service you need across turns runs with start_process (read it with process_output, stop it with stop_process).' : ''}` : '';
|
|
85
|
+
return lines.length ? `\n\nProcess for code changes (company Harness):\n${lines.join('\n')}\n${process.platform === 'darwin' ? 'A bash command stops after 10 minutes unless you pass timeout in seconds, at most 1800: a long suite needs that, not another tool. End-to-end and UI checks run in bash with the project\'s Playwright (@playwright/test; add it with npm when the project lacks it) and the browsers installed on this Mac, against a server the test or start_process starts; while a local proxy listens they need run_with_network. When Playwright\'s browsers are missing, ask with run_on_host to run `npx playwright install chromium`.' : 'End-to-end tests that start a local server and a browser run through run_with_network, which the user approves.'} If a check cannot run because the machine lacks something (a runtime such as a JDK, Docker, a database or another service, the network), do not stop there: request the approval that makes it run (run_with_network, run_with_docker, start_docker, or run_on_host to install what is missing) or ask the member with ask_user; never install or start anything without that approval.\nA command stops after 10 minutes and anything left running in the background stops when the turn ends: run the checks that cover your change first and a long suite in parts, not in the background.${process.platform === 'darwin' ? ' A dev server, watcher or local service you need across turns runs with start_process (read it with process_output, stop it with stop_process).' : ''}` : '';
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
// Every helper checks the brief it was given: it may object with evidence, it
|
|
@@ -173,7 +174,7 @@ export class RunProcess {
|
|
|
173
174
|
// asked for, and where the harness last sent the agent back (to read
|
|
174
175
|
// its answer and see whether the code moved).
|
|
175
176
|
ledger: [], disputes: [], verifyFails: [], claimsAsked: false, claimsAt: null, claimsRechecked: false, claimsDone: false, mainReviews: 0, minorFindings: 0,
|
|
176
|
-
sentAt: null, sentDigest: null, rejudged: false, reviewSettled: false,
|
|
177
|
+
sentAt: null, sentDigest: null, rejudged: false, reviewSettled: false,
|
|
177
178
|
// How much of the suite the member wants run (check-questions.mjs).
|
|
178
179
|
scopeChoice: null, scopeWords: null, fullAsked: asksFullChecks(request), pushAllowedOn: undefined,
|
|
179
180
|
// The patch's risk lane (workflow.lanes) and the main agent's own web lookups.
|
|
@@ -197,15 +198,17 @@ export class RunProcess {
|
|
|
197
198
|
changedWithoutPlan() {
|
|
198
199
|
this.unplannedChange = true; this.planSkipped = true;
|
|
199
200
|
}
|
|
200
|
-
|
|
201
|
+
// `owner`: the conversation the command ran in, for what passed in which repository.
|
|
202
|
+
async afterTool(name, args, ok, digestOf, owner = null) {
|
|
201
203
|
if (['write', 'edit'].includes(name) && ok) this.edited = true;
|
|
202
204
|
if (name === 'bash' && isCheckCommand(args?.command, this.checks.commands)) {
|
|
203
205
|
const scope = checkScope(args.command, this.checks.commands), digest = await digestOf(), last = this.lastCheck;
|
|
204
206
|
this.checksRun += 1; if (!ok) this.checksFailed += 1;
|
|
205
207
|
// A lint after a partial test run does not make it the whole suite.
|
|
206
208
|
this.lastCheck = { ok, digest, mutations: this.mutations, scope: scope ?? (last?.scope === 'partial' && last.digest === digest ? 'partial' : 'other') };
|
|
207
|
-
|
|
208
|
-
|
|
209
|
+
await recordCheck(this, owner, args.command, scope, ok, suitePart(args.command));
|
|
210
|
+
// The member's "run it all" covers one run in each repository a push needs; after a failure they are asked again.
|
|
211
|
+
if (scope === 'suite' && this.scopeChoice === 'full' && (!ok || !this.fullFor?.size)) { this.scopeChoice = null; this.fullFor = null; }
|
|
209
212
|
// A command may change files, so it counts. Without a git repository to
|
|
210
213
|
// compare the code against, one that only reads does not: counting every
|
|
211
214
|
// command reported code changes in turns that only read and checked.
|
|
@@ -298,7 +301,7 @@ export async function completionGate(managed, run, signal) {
|
|
|
298
301
|
}
|
|
299
302
|
if (policy.verify === 'require' && (!run.verified || run.scopeWords) && !run.docsOnly && !verifyGaveUp && run.waivedDigest !== digest) {
|
|
300
303
|
run.fixLoops += 1;
|
|
301
|
-
const list = run.checks.commands.length ? run.checks.commands.map(c => '`' + c + '`').join(', ') : 'the tests, type check or build that cover the change';
|
|
304
|
+
const list = run.checks.commands.length ? relevantChecks(managed, run.checks.commands).map(c => '`' + c + '`').join(', ') : 'the tests, type check or build that cover the change';
|
|
302
305
|
const words = run.scopeWords; run.scopeWords = null;
|
|
303
306
|
const ask = words ? `The member answered about testing: "${String(words).slice(0, 500)}". Follow that and fix what fails.`
|
|
304
307
|
: run.scopeChoice === 'partial' ? 'Run only the tests that cover your change (one file, a filter, one package), not the whole suite, and fix what fails.'
|
|
@@ -446,7 +449,7 @@ export function processEditTools(session, tools) {
|
|
|
446
449
|
}
|
|
447
450
|
return result;
|
|
448
451
|
}
|
|
449
|
-
finally { await session.run?.afterTool(tool.name, args[1], ok, () => session.digest()); await session.refreshReview(); }
|
|
452
|
+
finally { await session.run?.afterTool(tool.name, args[1], ok, () => session.digest(), session); await session.refreshReview(); }
|
|
450
453
|
} };
|
|
451
454
|
});
|
|
452
455
|
}
|
|
@@ -8,7 +8,7 @@ const PUBLIC_REF = /^[A-Za-z0-9][A-Za-z0-9._:@~-]{0,159}$/;
|
|
|
8
8
|
const REVISION = /^[A-Za-z0-9][A-Za-z0-9._:~-]{0,159}$/;
|
|
9
9
|
const DIGEST = /^sha256:[a-f0-9]{64}$/;
|
|
10
10
|
const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
11
|
-
const MAX_PENDING = 32, MAX_RECENT = 64, DEFAULT_TTL_MS = 5 * 60_000;
|
|
11
|
+
const MAX_PENDING = 32, MAX_RECENT = 64, DEFAULT_TTL_MS = 5 * 60_000, MAX_TTL_MS = 30 * 60_000;
|
|
12
12
|
|
|
13
13
|
type Identity = { projectRef: string; runtimeInstanceId: string; sessionRef: string; taskId: string; taskRunId: string;
|
|
14
14
|
agentOperationId: string; toolCallId: string };
|
|
@@ -161,7 +161,7 @@ export class PiApprovalBroker {
|
|
|
161
161
|
if (!binding || !authority || authority.taskState !== "active" || input.expectedTask && (authority.identity.taskId !== input.expectedTask.taskId || authority.identity.taskRunId !== input.expectedTask.taskRunId)
|
|
162
162
|
|| !REF.test(authority.identity.agentOperationId)
|
|
163
163
|
|| !toolCallId || binding.pending.size >= MAX_PENDING) return this.#unavailable(input);
|
|
164
|
-
const now = Date.now(), requestedAt = new Date(now).toISOString(), expiresAt = new Date(now + Math.min(Math.max(input.ttlMs ?? DEFAULT_TTL_MS, 5_000),
|
|
164
|
+
const now = Date.now(), requestedAt = new Date(now).toISOString(), expiresAt = new Date(now + Math.min(Math.max(input.ttlMs ?? DEFAULT_TTL_MS, 5_000), MAX_TTL_MS)).toISOString();
|
|
165
165
|
const approvalRef = ref("approval", binding.secret, [toolCallId, now, randomUUID()]);
|
|
166
166
|
const decisionToken = randomBytes(32).toString("base64url");
|
|
167
167
|
const nextRevision = this.#nextRevision(binding, "requested", approvalRef);
|