axstack 0.54.0 → 0.56.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/docs/concepts.md +3 -1
- package/docs/installation.md +5 -2
- package/docs/workflows.md +3 -1
- package/package.json +1 -1
- package/profiles/presets/claude-only.json +9 -0
- package/profiles/presets/codex-only.json +9 -0
- package/profiles/presets/mixed.json +9 -0
- package/skills/axstack/references/fleet-dispatch.md +166 -0
- package/skills/axstack/references/fleet-route-schema.md +325 -0
- package/skills/axstack/references/fleet-verification.md +102 -0
- package/skills/axstack/references/lifecycle.md +19 -2
- package/skills/axstack/references/role-roster.md +11 -0
- package/skills/axstack/references/routing.md +14 -0
- package/skills/axstack/references/run-record.md +83 -0
- package/skills/axstack/references/t3-runtime.md +174 -7
- package/skills/axstack/references/ui-verification.md +67 -4
- package/skills/axstack/references/workspace-hygiene.md +8 -0
- package/skills/axstack/scripts/dispatch-plan.js +52 -8
- package/skills/axstack/scripts/fleet-config.js +78 -0
- package/skills/axstack/scripts/fleet-destination.js +39 -0
- package/skills/axstack/scripts/fleet-dispatch.js +47 -0
- package/skills/axstack/scripts/fleet-facts.js +161 -0
- package/skills/axstack/scripts/fleet-inventory.js +76 -0
- package/skills/axstack/scripts/fleet-proc.js +31 -0
- package/skills/axstack/scripts/fleet-receipt.js +130 -0
- package/skills/axstack/scripts/fleet-route-cli.js +78 -0
- package/skills/axstack/scripts/fleet-route.js +86 -0
- package/skills/axstack/scripts/fleet-sample-cli.js +81 -0
- package/skills/axstack/scripts/fleet-sample-io.js +59 -0
- package/skills/axstack/scripts/fleet-sample-manifest.js +34 -0
- package/skills/axstack/scripts/fleet-sample.js +48 -0
- package/skills/axstack/scripts/resolve-models.js +8 -4
- package/skills/axstack-cleanup/SKILL.md +21 -0
- package/skills/axstack-implement/SKILL.md +12 -5
- package/skills/axstack-review/references/review-candidate.md +1 -1
- package/skills/axstack-verify/SKILL.md +17 -6
- package/skills/axstack-watch/SKILL.md +3 -0
- package/skills/axstack-watch/references/watch-runtime.md +18 -5
- package/skills/voa/SKILL.md +96 -0
- package/skills/voa/references/observations.md +79 -0
- package/skills/voa/references/recovery.md +94 -0
- package/skills/voa/references/verification.md +35 -0
- package/skills/voa/scripts/cleanup.js +39 -0
- package/skills/voa/scripts/evaluate.js +122 -0
- package/skills/voa/scripts/observations.js +68 -0
- package/skills/voa/scripts/records.js +48 -0
- package/skills/voa/scripts/voa.js +43 -0
- package/src/installer.js +3 -2
- package/src/instructions.js +11 -3
- package/src/roles.js +2 -2
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Saved evidence validator. A pass binds supplied facts; it cannot authenticate them.
|
|
2
|
+
import { canonical, record, text, time } from './fleet-config.js';
|
|
3
|
+
|
|
4
|
+
function options(value) {
|
|
5
|
+
if (!Array.isArray(value)) return record(value) ? value : null;
|
|
6
|
+
if (value.some((v) => !text(v?.id) || !['string', 'boolean'].includes(typeof v.value))
|
|
7
|
+
|| new Set(value.map((v) => v.id)).size !== value.length) return null;
|
|
8
|
+
return Object.fromEntries(value.map((v) => [v.id, v.value]));
|
|
9
|
+
}
|
|
10
|
+
const same = (a, b) => a !== undefined && b !== undefined && canonical(a) === canonical(b);
|
|
11
|
+
const absolute = (path) => text(path) && path.startsWith('/') && !path.split('/').some((p) => p === '..' || p === '.');
|
|
12
|
+
|
|
13
|
+
export function inspectFleetReceipt(receipt, expected) {
|
|
14
|
+
const terms = [];
|
|
15
|
+
const term = (code, pass, observed, missing = observed === undefined || observed === null) => {
|
|
16
|
+
terms.push({ code, state: pass ? 'pass' : missing ? 'unknown' : 'fail', evidence: observed ?? null });
|
|
17
|
+
};
|
|
18
|
+
const r = receipt ?? {};
|
|
19
|
+
term('receipt-version', r.schemaVersion === 1, r.schemaVersion);
|
|
20
|
+
const a = expected.attempt;
|
|
21
|
+
const s = expected.recommendation.selected;
|
|
22
|
+
const request = expected.recommendation.request;
|
|
23
|
+
term('receipt-origin', text(a?.origin?.threadId) && a?.origin?.environmentId === expected.recommendation.originEnvironmentId
|
|
24
|
+
&& same(r.attempt?.origin, a?.origin), r.attempt?.origin,
|
|
25
|
+
!text(a?.origin?.threadId) || r.attempt?.origin == null);
|
|
26
|
+
term('receipt-attempt', text(a?.key) && text(a?.clientRequestId) && text(a?.origin?.taskId)
|
|
27
|
+
&& same(r.attempt, a), r.attempt);
|
|
28
|
+
term('receipt-recommendation', r.recommendationJSON === expected.recommendationJSON, r.recommendationJSON);
|
|
29
|
+
term('receipt-config', r.configDigest === expected.recommendation.configDigest, r.configDigest);
|
|
30
|
+
const status = r.status?.value;
|
|
31
|
+
const submitted = r.submission;
|
|
32
|
+
const thread = r.thread?.value?.thread;
|
|
33
|
+
const remote = s.environmentId !== expected.recommendation.originEnvironmentId;
|
|
34
|
+
const child = (v) => remote ? v?.remoteChild?.threadId : v?.childThreadId;
|
|
35
|
+
const env = (v) => remote ? v?.remoteChild?.environmentId : a?.origin?.environmentId;
|
|
36
|
+
term('receipt-destination', env(status) === s.environmentId && env(submitted) === s.environmentId
|
|
37
|
+
&& text(child(status)) && child(status) === child(submitted)
|
|
38
|
+
&& r.thread?.environmentId === s.environmentId && thread?.threadId === child(status),
|
|
39
|
+
{ submitted: submitted?.remoteChild ?? submitted?.childThreadId,
|
|
40
|
+
status: status?.remoteChild ?? status?.childThreadId, thread: r.thread?.environmentId });
|
|
41
|
+
term('receipt-title', text(a?.title) && thread?.title === a.title, thread?.title);
|
|
42
|
+
term('receipt-project', thread?.projectId === s.projectId, thread?.projectId);
|
|
43
|
+
for (const [name, tool, environmentId, args] of [
|
|
44
|
+
['status', 'task_status', a?.origin?.environmentId, { taskId: a?.origin?.taskId }],
|
|
45
|
+
['thread', 't3_thread_read', s.environmentId, { threadId: child(status) }],
|
|
46
|
+
['terminal', 't3_thread_wait', s.environmentId, { threadId: child(status), runId: thread?.latestRunId }],
|
|
47
|
+
]) {
|
|
48
|
+
const read = r[name];
|
|
49
|
+
term('receipt-observation', read?.tool === tool && read?.environmentId === environmentId
|
|
50
|
+
&& same(read?.args, args) && time(read?.observedAt) && read.observedAt <= expected.now
|
|
51
|
+
&& text(read?.evidence), read);
|
|
52
|
+
}
|
|
53
|
+
term('receipt-origin', status?.taskId === a?.origin?.taskId && submitted?.taskId === a?.origin?.taskId,
|
|
54
|
+
status?.taskId);
|
|
55
|
+
const target = r.requested?.target;
|
|
56
|
+
term('receipt-requested', target?.environmentId === s.environmentId && target?.projectId === s.projectId
|
|
57
|
+
&& target?.providerInstanceId === request.providerInstanceId && target?.model === request.model
|
|
58
|
+
&& same(options(target?.options), request.options) && r.requested?.runtimeMode === request.runtimeMode,
|
|
59
|
+
r.requested);
|
|
60
|
+
term('receipt-effective', status?.providerInstanceId === request.providerInstanceId
|
|
61
|
+
&& status?.model === request.model && thread?.providerInstanceId === request.providerInstanceId
|
|
62
|
+
&& thread?.model === request.model && same(options(status?.options), request.options)
|
|
63
|
+
&& thread?.runtimeMode === request.runtimeMode, { provider: status?.providerInstanceId,
|
|
64
|
+
model: status?.model, options: status?.options ?? null, runtimeMode: thread?.runtimeMode },
|
|
65
|
+
status?.options == null || thread?.runtimeMode == null);
|
|
66
|
+
const terminal = r.terminal?.value;
|
|
67
|
+
term('receipt-terminal', status?.status === 'completed' && status?.latestTerminalStatus === 'completed'
|
|
68
|
+
&& status?.workState === 'result_available' && status?.hasPendingChildRuns === false
|
|
69
|
+
&& ['completed', 'idle'].includes(thread?.status) && thread?.activeRunId === null
|
|
70
|
+
&& terminal?.status === 'completed' && terminal?.timedOut === false, { status, terminal },
|
|
71
|
+
status?.status == null || status?.workState == null || status?.hasPendingChildRuns == null
|
|
72
|
+
|| status?.latestTerminalStatus == null || terminal?.status == null || terminal?.timedOut == null
|
|
73
|
+
|| thread?.activeRunId === undefined);
|
|
74
|
+
term('receipt-question', status?.hasPendingQuestion === false && status?.pendingApproval === null
|
|
75
|
+
&& thread?.pendingRequestCount === 0, { hasPendingQuestion: status?.hasPendingQuestion,
|
|
76
|
+
pendingApproval: status?.pendingApproval, pendingRequestCount: thread?.pendingRequestCount },
|
|
77
|
+
status?.hasPendingQuestion == null || status?.pendingApproval === undefined || thread?.pendingRequestCount == null);
|
|
78
|
+
const questions = r.questions;
|
|
79
|
+
term('receipt-question', questions?.tool === 't3_pending_request'
|
|
80
|
+
&& questions?.environmentId === s.environmentId
|
|
81
|
+
&& same(questions?.args, { action: 'list', threadId: child(status) })
|
|
82
|
+
&& time(questions?.observedAt) && questions.observedAt <= expected.now
|
|
83
|
+
&& text(questions?.evidence) && Array.isArray(questions?.value?.requests)
|
|
84
|
+
&& questions.value.requests.length === 0, questions,
|
|
85
|
+
questions == null || questions.value?.requests === undefined);
|
|
86
|
+
const current = thread?.latestRunId;
|
|
87
|
+
const runs = r.thread?.value?.recentRuns;
|
|
88
|
+
const latest = Array.isArray(runs) ? runs.find((v) => v.runId === current) : null;
|
|
89
|
+
term('receipt-run', text(current) && status?.latestTerminalRunId === current
|
|
90
|
+
&& terminal?.threadId === child(status) && terminal?.runId === current
|
|
91
|
+
&& (!terminal?.resultRun || (terminal.resultRun.runId === current && terminal.resultRun.status === 'completed'))
|
|
92
|
+
&& (!remote || (r.remoteChild?.environmentId === s.environmentId
|
|
93
|
+
&& r.remoteChild?.threadId === child(status) && r.remoteChild?.runId === current))
|
|
94
|
+
&& latest?.status === 'completed' && Number.isSafeInteger(latest?.ordinal)
|
|
95
|
+
&& runs.every((v) => Number.isSafeInteger(v.ordinal) && v.ordinal <= latest.ordinal),
|
|
96
|
+
{ current, originTerminal: status?.latestTerminalRunId, terminal, runs },
|
|
97
|
+
!current || !status?.latestTerminalRunId || !terminal?.runId || !Array.isArray(runs)
|
|
98
|
+
|| (remote && !r.remoteChild?.runId));
|
|
99
|
+
const marker = `AXSTACK-DONE key=${a?.key} head=${a?.candidate} report=${r.evidence?.path}`;
|
|
100
|
+
const finalMarker = (value) => {
|
|
101
|
+
if (typeof value !== 'string') return null;
|
|
102
|
+
const lines = value.trim().split('\n');
|
|
103
|
+
const markers = lines.filter((line) => line.startsWith('AXSTACK-'));
|
|
104
|
+
if (markers.length !== 1) return null;
|
|
105
|
+
const trailer = lines.slice(lines.indexOf(markers[0]) + 1);
|
|
106
|
+
return trailer.length === 0 || (trailer.length === 1
|
|
107
|
+
&& /^PR https?:\/\/\S+, head [a-f0-9]{7,40}, checks and reviews .+$/.test(trailer[0]))
|
|
108
|
+
? markers[0] : null;
|
|
109
|
+
};
|
|
110
|
+
const items = r.thread?.value?.items;
|
|
111
|
+
const final = Array.isArray(items) ? items.filter((v) => v.runId === current && v.createdBy === 'agent').at(-1) : null;
|
|
112
|
+
term('receipt-marker', /^[a-f0-9]{40}$/.test(a?.candidate ?? '')
|
|
113
|
+
&& finalMarker(status?.latestTerminalSummary) === marker && finalMarker(final?.text) === marker,
|
|
114
|
+
{ origin: status?.latestTerminalSummary, destination: final?.text });
|
|
115
|
+
term('receipt-host-path', r.evidence?.hostId === s.hostId && r.evidence?.environmentId === s.environmentId
|
|
116
|
+
&& absolute(r.evidence?.path) && finalMarker(status?.latestTerminalSummary)?.endsWith(` report=${r.evidence.path}`), r.evidence);
|
|
117
|
+
if (a?.title?.startsWith('Reviewer: ')) {
|
|
118
|
+
term('receipt-isolation', r.requested?.isolation === 'worktree', r.requested?.isolation);
|
|
119
|
+
}
|
|
120
|
+
if (a?.title?.startsWith('Reviewer: ') || r.checkout != null || thread?.worktreePath != null || r.requested?.isolation === 'worktree') {
|
|
121
|
+
const c = r.checkout;
|
|
122
|
+
term('receipt-checkout', c?.hostId === s.hostId && c?.environmentId === s.environmentId
|
|
123
|
+
&& absolute(c?.path) && c.path === thread?.worktreePath
|
|
124
|
+
&& /^[a-f0-9]{40}$/.test(a?.base ?? '') && c?.base === a.base && c?.candidate === a.candidate
|
|
125
|
+
&& c?.readback?.hostId === s.hostId && c?.readback?.environmentId === s.environmentId
|
|
126
|
+
&& c?.readback?.path === c.path && c?.readback?.base === a.base && c?.readback?.candidate === a.candidate
|
|
127
|
+
&& text(c?.readback?.evidence), c);
|
|
128
|
+
}
|
|
129
|
+
return terms;
|
|
130
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { sampleMain, sampleHelp } from './fleet-sample-cli.js';
|
|
3
|
+
import { recommend, parseFleetConfig } from './fleet-route.js';
|
|
4
|
+
|
|
5
|
+
const help = `Usage: bun fleet-route.js recommend --input <snapshot.json> --now <epoch-ms> [--json]
|
|
6
|
+
[--task <name>] [--config <fleet-routing.json>] [--config-digest <sha256>]
|
|
7
|
+
|
|
8
|
+
--input Saved schema-1 facts; no collection or dispatch
|
|
9
|
+
--now Required nonnegative integer evaluation time; no ambient clock
|
|
10
|
+
--task Override the saved request's task preference
|
|
11
|
+
--config Reviewed account-local config (default: ~/.config/haoshoku/fleet-routing.json)
|
|
12
|
+
--config-digest Run-pinned SHA-256; otherwise required in saved input.configDigest
|
|
13
|
+
--json Emit complete recommendation JSON; errors are JSON on stderr
|
|
14
|
+
--help Show this interface
|
|
15
|
+
|
|
16
|
+
Reads only the supplied input and config. Writes stdout/stderr only.
|
|
17
|
+
No network, authentication, dispatch, config changes or retained state.
|
|
18
|
+
Required session capabilities must be proven on the actual destination session;
|
|
19
|
+
an origin catalog or transferred credentials cannot establish them.
|
|
20
|
+
Exclusions with reasons are supplied in input.exclusions.hosts/routes.
|
|
21
|
+
Use sample or sample-recommend for bounded collection.
|
|
22
|
+
Exit codes: 0 recommendation/help, 1 input/config error, 2 no executable route.
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
const fail = (code) => { throw new Error(code); };
|
|
26
|
+
function readInput(path) {
|
|
27
|
+
let value;
|
|
28
|
+
try { value = readFileSync(path, 'utf8'); } catch { fail('missing-input'); }
|
|
29
|
+
try { return JSON.parse(value); } catch { fail('input-json'); }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function defaultConfigPath() {
|
|
33
|
+
return process.env.HOME ? `${process.env.HOME}/.config/haoshoku/fleet-routing.json` : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function main(argv) {
|
|
37
|
+
try {
|
|
38
|
+
if (argv.length === 1 && argv[0] === '--help') {
|
|
39
|
+
process.stdout.write(help + sampleHelp);
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
if (['sample', 'sample-recommend'].includes(argv[0])) return await sampleMain(argv, defaultConfigPath());
|
|
43
|
+
if (argv[0] !== 'recommend') fail('unexpected-command');
|
|
44
|
+
const args = {};
|
|
45
|
+
for (let i = 1; i < argv.length; i++) {
|
|
46
|
+
const flag = argv[i];
|
|
47
|
+
if (!['--input', '--now', '--task', '--config', '--config-digest', '--json'].includes(flag)) fail('unexpected-argument');
|
|
48
|
+
if (Object.hasOwn(args, flag)) fail('duplicate-argument');
|
|
49
|
+
if (flag === '--json') { args[flag] = true; continue; }
|
|
50
|
+
const value = argv[++i];
|
|
51
|
+
if (!value?.trim() || value.startsWith('--')) fail('missing-argument');
|
|
52
|
+
args[flag] = value;
|
|
53
|
+
}
|
|
54
|
+
if (!args['--input'] || !args['--now']) fail('missing-argument');
|
|
55
|
+
if (!/^\d+$/.test(args['--now'])) fail('evaluation-time');
|
|
56
|
+
const now = Number(args['--now']);
|
|
57
|
+
const input = readInput(args['--input']);
|
|
58
|
+
if (!input || typeof input !== 'object' || Array.isArray(input) || input.schemaVersion !== 1) fail('schema-version: input');
|
|
59
|
+
let configText;
|
|
60
|
+
const configPath = args['--config'] ?? defaultConfigPath();
|
|
61
|
+
try {
|
|
62
|
+
if (!configPath) fail('missing-config');
|
|
63
|
+
configText = readFileSync(configPath, 'utf8');
|
|
64
|
+
} catch { fail('missing-config'); }
|
|
65
|
+
const configDigest = args['--config-digest'] ?? input.configDigest;
|
|
66
|
+
parseFleetConfig(configText, configDigest);
|
|
67
|
+
if (input.configDigest !== undefined && input.configDigest !== configDigest) fail('config-digest-mismatch');
|
|
68
|
+
if (input.configText !== undefined && input.configText !== configText) fail('config-snapshot-mismatch');
|
|
69
|
+
const result = recommend({ ...input, configText, configDigest,
|
|
70
|
+
request: args['--task'] ? { ...input.request, task: args['--task'] } : input.request }, now);
|
|
71
|
+
process.stdout.write(args['--json'] ? `${JSON.stringify(result)}\n`
|
|
72
|
+
: `${result.stage}: ${result.selected?.hostId ?? 'no host'} (${result.reason})\n`);
|
|
73
|
+
return result.selected ? 0 : 2;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
process.stderr.write(`${JSON.stringify({ error: { code: error.message.split(':')[0], message: error.message } })}\n`);
|
|
76
|
+
return 1;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Pure supplied-facts API. No clock, filesystem, networking or dispatch.
|
|
2
|
+
import { parseFleetConfig, validateInput } from './fleet-config.js';
|
|
3
|
+
import { assignmentCounts, routeReasons, telemetry } from './fleet-facts.js';
|
|
4
|
+
export { parseFleetConfig } from './fleet-config.js';
|
|
5
|
+
|
|
6
|
+
export function recommend(input, now) {
|
|
7
|
+
validateInput(input, now);
|
|
8
|
+
const config = parseFleetConfig(input.configText, input.configDigest);
|
|
9
|
+
const counts = assignmentCounts(input, config.hosts, now);
|
|
10
|
+
const preference = Object.hasOwn(config.taskOrders, input.request.task)
|
|
11
|
+
? config.taskOrders[input.request.task] : config.defaultOrder;
|
|
12
|
+
const eligible = [];
|
|
13
|
+
const excluded = [];
|
|
14
|
+
for (const hostId of preference) {
|
|
15
|
+
const host = config.hosts.find((h) => h.id === hostId);
|
|
16
|
+
const destination = input.destinations.find((d) => d.environmentId === host.environmentId);
|
|
17
|
+
const routes = config.routes.filter((r) => r.environmentId === host.environmentId
|
|
18
|
+
&& r.originEnvironmentId === input.origin.environmentId);
|
|
19
|
+
let accepted = null;
|
|
20
|
+
for (const route of routes) {
|
|
21
|
+
const facts = input.routeFacts.find((f) => f.routeId === route.id);
|
|
22
|
+
const reasons = routeReasons(route, host, destination, facts, input, now);
|
|
23
|
+
if (reasons.length) {
|
|
24
|
+
excluded.push({ hostId, environmentId: host.environmentId, routeId: route.id, reasons,
|
|
25
|
+
exclusion: (Object.hasOwn(input.exclusions.hosts, hostId) ? input.exclusions.hosts[hostId] : null)
|
|
26
|
+
?? (Object.hasOwn(input.exclusions.routes, route.id) ? input.exclusions.routes[route.id] : null),
|
|
27
|
+
evidence: { configuredRoute: route.evidence ?? null, observedRoute: facts?.evidence ?? null,
|
|
28
|
+
destination: destination?.evidence ?? null, destinationSnapshotId: destination?.snapshotId ?? null,
|
|
29
|
+
configuredCapabilities: host.capabilities, observedCapabilities: destination?.capabilities ?? null } });
|
|
30
|
+
} else if (!accepted) {
|
|
31
|
+
accepted = { hostId, environmentId: host.environmentId, projectId: destination.project.id,
|
|
32
|
+
repositoryId: destination.project.repositoryId, routeId: route.id, routeKind: route.kind,
|
|
33
|
+
destinationSnapshotId: destination.snapshotId, destinationObservedAt: destination.observedAt,
|
|
34
|
+
...(destination.capabilitiesDigest !== undefined
|
|
35
|
+
? { destinationCapabilitiesDigest: destination.capabilitiesDigest } : {}),
|
|
36
|
+
destinationEvidence: destination.evidence, routeEvidence: facts.evidence, versions: destination.versions,
|
|
37
|
+
execution: destination.execution, capabilities: destination.capabilities,
|
|
38
|
+
fallbackLimitBp: host.fallbackLimitBp, assignmentCount: counts.byEnvironment[host.environmentId],
|
|
39
|
+
telemetry: telemetry(input.telemetry.find((s) => s.environmentId === host.environmentId), now) };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (accepted) eligible.push(accepted);
|
|
43
|
+
if (!routes.length) excluded.push({ hostId, environmentId: host.environmentId, routeId: null, reasons: ['route-missing'], exclusion: null });
|
|
44
|
+
}
|
|
45
|
+
const pool = eligible.filter((h) => config.pool.includes(h.hostId));
|
|
46
|
+
const measured = (h) => h.telemetry.status === 'measured';
|
|
47
|
+
const rank = (h) => preference.indexOf(h.hostId);
|
|
48
|
+
const byLoad = (a, b) => a.telemetry.loadBp - b.telemetry.loadBp || rank(a) - rank(b);
|
|
49
|
+
const rankingCounts = counts.pendingLowerBound ?? counts.byEnvironment;
|
|
50
|
+
const under = pool.filter((h) => measured(h) && h.telemetry.loadBp <= config.balanceLimitBp);
|
|
51
|
+
let stage; let reason; let selected = null;
|
|
52
|
+
if (!eligible.length) { stage = 'no-route'; reason = 'no-executable-route'; }
|
|
53
|
+
else if (under.length) {
|
|
54
|
+
stage = 'balance'; reason = 'pool-under-limit';
|
|
55
|
+
selected = under.sort((a, b) => rankingCounts[a.environmentId] - rankingCounts[b.environmentId] || byLoad(a, b))[0];
|
|
56
|
+
} else if (pool.length && pool.every((h) => !measured(h))) {
|
|
57
|
+
stage = 'pool-unknown'; reason = 'pool-all-unknown'; selected = pool[0];
|
|
58
|
+
} else {
|
|
59
|
+
stage = 'fallback';
|
|
60
|
+
selected = eligible.find((h) => measured(h) && h.telemetry.loadBp <= h.fallbackLimitBp);
|
|
61
|
+
if (selected) reason = 'first-within-limit';
|
|
62
|
+
else {
|
|
63
|
+
const observed = eligible.filter(measured).sort(byLoad);
|
|
64
|
+
selected = observed[0] ?? eligible[0];
|
|
65
|
+
reason = observed.length ? 'least-loaded-over-limit' : 'all-telemetry-unknown';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const flags = {
|
|
69
|
+
countsUnknown: counts.inventoryStatus === 'unknown', poolEmpty: pool.length === 0,
|
|
70
|
+
poolPartlyUnknown: pool.some(measured) && pool.some((h) => !measured(h)),
|
|
71
|
+
telemetryUnknown: eligible.some((h) => !measured(h)),
|
|
72
|
+
overloaded: selected ? measured(selected) && selected.telemetry.loadBp > selected.fallbackLimitBp : false,
|
|
73
|
+
localAuthorOnly: input.request.role === 'author' && eligible.length > 0
|
|
74
|
+
&& eligible.every((h) => h.environmentId === input.origin.environmentId),
|
|
75
|
+
};
|
|
76
|
+
// Copy supplied nested facts so callers cannot mutate input through the result.
|
|
77
|
+
return structuredClone({ schemaVersion: 1, configDigest: input.configDigest, evaluatedAt: now,
|
|
78
|
+
originEnvironmentId: input.origin.environmentId, request: input.request,
|
|
79
|
+
stage, reason: flags.localAuthorOnly ? 'author-local-only' : reason, decisionReason: reason,
|
|
80
|
+
selected, flags, counts, eligible, excluded });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (import.meta.main) {
|
|
84
|
+
const { main } = await import('./fleet-route-cli.js');
|
|
85
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
86
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { parseFleetConfig, recommend } from './fleet-route.js';
|
|
3
|
+
import { sampleFleet } from './fleet-sample.js';
|
|
4
|
+
import { sampleOptions, validateManifest } from './fleet-sample-manifest.js';
|
|
5
|
+
export const sampleHelp = `
|
|
6
|
+
Usage: bun fleet-route.js sample --fleet <manifest.json> --config-digest <sha256> [--config <path>] [--json]
|
|
7
|
+
bun fleet-route.js sample-recommend --fleet <manifest.json> --input <snapshot.json>
|
|
8
|
+
--config-digest <sha256> [--config <path>] [--json]
|
|
9
|
+
--fleet Schema-1 manifest; exact host/environment joins; local or SSH transport
|
|
10
|
+
--interval-ms Integer 5000..60000; default 5000; minimum gap between two reads
|
|
11
|
+
--timeout-ms Per-host whole-collection deadline; default 10000; interval+1000..120000
|
|
12
|
+
--config Same reviewed config/default path as recommend; explicit digest required
|
|
13
|
+
--input sample-recommend only: C1 access/project/capability/assignment facts
|
|
14
|
+
--json Full sample report or {input,evaluatedAt,recommendation} replay envelope
|
|
15
|
+
|
|
16
|
+
Sample commands read Linux /proc/stat and /proc/meminfo locally or through bounded,
|
|
17
|
+
noninteractive SSH argv to validated MagicDNS short names with existing identities.
|
|
18
|
+
They use collector receipt time, collect hosts concurrently, and write stdout/stderr only.
|
|
19
|
+
Window duration brackets transport plus reads and interval; it is not native history.
|
|
20
|
+
Unknown telemetry never grants a route. No dispatch, config mutation or retained state.
|
|
21
|
+
Exit codes: 0 sample/recommendation, 1 input/config error, 2 no executable route.
|
|
22
|
+
`;
|
|
23
|
+
const fail = (code) => { throw Error(code); };
|
|
24
|
+
function read(path, name, json = true) {
|
|
25
|
+
let text;
|
|
26
|
+
try { text = readFileSync(path, 'utf8'); } catch { fail(`missing-${name}`); }
|
|
27
|
+
if (!json) return text;
|
|
28
|
+
try { return JSON.parse(text); } catch { fail(`${name}-json`); }
|
|
29
|
+
}
|
|
30
|
+
export async function sampleMain(argv, defaultConfigPath) {
|
|
31
|
+
const command = argv[0]; const args = {};
|
|
32
|
+
const flags = ['--fleet', '--config', '--config-digest', '--interval-ms', '--timeout-ms', '--json',
|
|
33
|
+
...(command === 'sample-recommend' ? ['--input', '--task'] : [])];
|
|
34
|
+
for (let i = 1; i < argv.length; i++) {
|
|
35
|
+
const flag = argv[i];
|
|
36
|
+
if (!flags.includes(flag)) fail('unexpected-argument');
|
|
37
|
+
if (Object.hasOwn(args, flag)) fail('duplicate-argument');
|
|
38
|
+
if (flag === '--json') { args[flag] = true; continue; }
|
|
39
|
+
const value = argv[++i];
|
|
40
|
+
if (!value?.trim() || value.startsWith('--')) fail('missing-argument');
|
|
41
|
+
args[flag] = value;
|
|
42
|
+
}
|
|
43
|
+
if (!args['--fleet'] || (command === 'sample-recommend' && !args['--input'])) fail('missing-argument');
|
|
44
|
+
const numeric = (name, fallback, reason) => {
|
|
45
|
+
if (args[name] === undefined) return fallback;
|
|
46
|
+
if (!/^\d+$/.test(args[name])) fail(reason);
|
|
47
|
+
return Number(args[name]);
|
|
48
|
+
};
|
|
49
|
+
const options = sampleOptions({ intervalMs: numeric('--interval-ms', 5000, 'sample-interval'),
|
|
50
|
+
timeoutMs: numeric('--timeout-ms', 10000, 'sample-timeout') });
|
|
51
|
+
const configPath = args['--config'] ?? defaultConfigPath;
|
|
52
|
+
const configText = read(configPath, 'config', false);
|
|
53
|
+
const configDigest = args['--config-digest'];
|
|
54
|
+
const config = parseFleetConfig(configText, configDigest);
|
|
55
|
+
const manifest = validateManifest(read(args['--fleet'], 'manifest'), config.hosts);
|
|
56
|
+
let input;
|
|
57
|
+
if (command === 'sample-recommend') {
|
|
58
|
+
input = read(args['--input'], 'input');
|
|
59
|
+
if (input?.configDigest !== undefined && input.configDigest !== configDigest) fail('config-digest-mismatch');
|
|
60
|
+
if (input?.configText !== undefined && input.configText !== configText) fail('config-snapshot-mismatch');
|
|
61
|
+
input = { ...input, configText, configDigest, request: args['--task']
|
|
62
|
+
? { ...input.request, task: args['--task'] } : input?.request };
|
|
63
|
+
recommend(input, Date.now()); // Validate supplied security facts before any network read.
|
|
64
|
+
}
|
|
65
|
+
const controller = new AbortController();
|
|
66
|
+
const cancel = () => controller.abort();
|
|
67
|
+
process.once('SIGINT', cancel); process.once('SIGTERM', cancel);
|
|
68
|
+
let samples;
|
|
69
|
+
try { samples = await sampleFleet(manifest, config.hosts, options, { signal: controller.signal }); }
|
|
70
|
+
finally { process.removeListener('SIGINT', cancel); process.removeListener('SIGTERM', cancel); }
|
|
71
|
+
const evaluatedAt = Date.now();
|
|
72
|
+
if (command === 'sample') {
|
|
73
|
+
process.stdout.write(`${JSON.stringify({ ...samples, telemetry: samples.telemetry.map((s) => ({ ...s, ageMs: evaluatedAt - s.receivedAt })),
|
|
74
|
+
configDigest, collectedAt: evaluatedAt })}\n`);
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
input = { ...input, telemetry: samples.telemetry };
|
|
78
|
+
const recommendation = recommend(input, evaluatedAt);
|
|
79
|
+
process.stdout.write(`${JSON.stringify({ schemaVersion: 1, input, evaluatedAt, recommendation })}\n`);
|
|
80
|
+
return recommendation.selected ? 0 : 2;
|
|
81
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
export const PROC_COMMAND = "cat /proc/stat && printf '\\n---AXSTACK-MEM---\\n' && cat /proc/meminfo";
|
|
3
|
+
export function sshArgv(host) {
|
|
4
|
+
return ['ssh', '-T', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', '-o', 'IdentitiesOnly=yes',
|
|
5
|
+
'-o', 'ConnectTimeout=3', '-o', 'ConnectionAttempts=1', '-o', 'ServerAliveInterval=2',
|
|
6
|
+
'-o', 'ServerAliveCountMax=1', '-o', 'ControlMaster=no', '-o', 'ControlPath=none',
|
|
7
|
+
'-o', 'ClearAllForwardings=yes', '-o', 'PermitLocalCommand=no',
|
|
8
|
+
...(host.user ? ['-l', host.user] : []), '--', host.destination, PROC_COMMAND];
|
|
9
|
+
}
|
|
10
|
+
export function wait(ms, signal) {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const cancel = () => { clearTimeout(timer); signal.removeEventListener('abort', cancel); reject(signal.reason); };
|
|
13
|
+
const timer = setTimeout(() => { signal.removeEventListener('abort', cancel); resolve(); }, ms);
|
|
14
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
15
|
+
if (signal.aborted) cancel();
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
async function limited(stream, signal) {
|
|
19
|
+
const reader = stream.getReader(); const chunks = []; let size = 0;
|
|
20
|
+
const cancel = () => { void reader.cancel(); };
|
|
21
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
22
|
+
try {
|
|
23
|
+
while (true) {
|
|
24
|
+
if (signal.aborted) throw signal.reason;
|
|
25
|
+
const { done, value } = await reader.read();
|
|
26
|
+
if (done) break;
|
|
27
|
+
size += value.byteLength;
|
|
28
|
+
if (size > 65536) throw Error('malformed');
|
|
29
|
+
chunks.push(value);
|
|
30
|
+
}
|
|
31
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks));
|
|
32
|
+
} finally {
|
|
33
|
+
signal.removeEventListener('abort', cancel);
|
|
34
|
+
await reader.cancel();
|
|
35
|
+
reader.releaseLock();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function readProc(host, signal, spawn = Bun.spawn) {
|
|
39
|
+
if (host.transport === 'local') {
|
|
40
|
+
const stat = await readFile('/proc/stat', { encoding: 'utf8', signal });
|
|
41
|
+
const mem = await readFile('/proc/meminfo', { encoding: 'utf8', signal });
|
|
42
|
+
return `${stat}\n---AXSTACK-MEM---\n${mem}`;
|
|
43
|
+
}
|
|
44
|
+
const child = spawn(sshArgv(host), { stdin: 'ignore', stdout: 'pipe', stderr: 'ignore' });
|
|
45
|
+
const cancel = () => child.kill('SIGKILL');
|
|
46
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
47
|
+
try {
|
|
48
|
+
if (signal.aborted) { cancel(); throw signal.reason; }
|
|
49
|
+
const output = await limited(child.stdout, signal);
|
|
50
|
+
const code = await child.exited;
|
|
51
|
+
if (signal.aborted) throw signal.reason;
|
|
52
|
+
if (code !== 0) throw Error('failed');
|
|
53
|
+
return output;
|
|
54
|
+
} finally {
|
|
55
|
+
signal.removeEventListener('abort', cancel);
|
|
56
|
+
child.kill('SIGKILL');
|
|
57
|
+
await child.exited;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { record } from './fleet-config.js';
|
|
2
|
+
const fail = (reason) => { throw Error(reason); };
|
|
3
|
+
const keys = (obj, allowed) => record(obj) && Object.keys(obj).every((k) => allowed.includes(k));
|
|
4
|
+
export function validateManifest(manifest, hosts) {
|
|
5
|
+
if (manifest?.schemaVersion !== 1) fail('schema-version: manifest');
|
|
6
|
+
if (!keys(manifest, ['schemaVersion', 'hosts']) || !Array.isArray(manifest.hosts)
|
|
7
|
+
|| !manifest.hosts.length || manifest.hosts.length > 64) fail('manifest-schema');
|
|
8
|
+
const ids = new Set(); const destinations = new Set(); let local = false;
|
|
9
|
+
for (const h of manifest.hosts) {
|
|
10
|
+
if (!keys(h, ['hostId', 'environmentId', 'transport', 'destination', 'user'])) fail('manifest-schema');
|
|
11
|
+
if (!hosts.some((c) => c.id === h.hostId && c.environmentId === h.environmentId)) fail('manifest-host');
|
|
12
|
+
if (ids.has(h.hostId)) fail('manifest-duplicate');
|
|
13
|
+
ids.add(h.hostId);
|
|
14
|
+
if (!['local', 'ssh'].includes(h.transport)) fail('manifest-transport');
|
|
15
|
+
if (h.transport === 'local') {
|
|
16
|
+
if (h.destination !== undefined || h.user !== undefined) fail('manifest-schema');
|
|
17
|
+
if (local) fail('manifest-duplicate');
|
|
18
|
+
local = true;
|
|
19
|
+
} else {
|
|
20
|
+
// MagicDNS short names only. SSH aliases, IPs, ports and shell syntax are rejected.
|
|
21
|
+
if (typeof h.destination !== 'string' || !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(h.destination)
|
|
22
|
+
|| /^\d+$/.test(h.destination)) fail('manifest-destination');
|
|
23
|
+
if (h.user !== undefined && (typeof h.user !== 'string' || !/^[a-z_][a-z0-9_-]{0,31}$/.test(h.user))) fail('manifest-user');
|
|
24
|
+
if (destinations.has(h.destination)) fail('manifest-duplicate');
|
|
25
|
+
destinations.add(h.destination);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return manifest;
|
|
29
|
+
}
|
|
30
|
+
export function sampleOptions({ intervalMs = 5000, timeoutMs = 10000 } = {}) {
|
|
31
|
+
if (!Number.isSafeInteger(intervalMs) || intervalMs < 5000 || intervalMs > 60000) fail('sample-interval');
|
|
32
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < intervalMs + 1000 || timeoutMs > 120000) fail('sample-timeout');
|
|
33
|
+
return { intervalMs, timeoutMs };
|
|
34
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { parseProc, pressure } from './fleet-proc.js';
|
|
2
|
+
import { validateManifest, sampleOptions } from './fleet-sample-manifest.js';
|
|
3
|
+
import { readProc, wait } from './fleet-sample-io.js';
|
|
4
|
+
export { parseProc, pressure, validateManifest };
|
|
5
|
+
|
|
6
|
+
// Read functions and clocks are injectable for deterministic offline verification.
|
|
7
|
+
export async function sampleFleet(manifest, hosts, options, dependencies = {}) {
|
|
8
|
+
validateManifest(manifest, hosts);
|
|
9
|
+
const { intervalMs, timeoutMs } = sampleOptions(options);
|
|
10
|
+
const { clock = Date.now, monotonic = () => performance.now(), read = readProc, wait: pause = wait,
|
|
11
|
+
signal } = dependencies;
|
|
12
|
+
const telemetry = await Promise.all(manifest.hosts.map(async (host) => {
|
|
13
|
+
const controller = new AbortController();
|
|
14
|
+
const cancel = () => controller.abort(Error('cancelled'));
|
|
15
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
16
|
+
if (signal?.aborted) cancel();
|
|
17
|
+
const timer = setTimeout(() => controller.abort(Error('timeout')), timeoutMs);
|
|
18
|
+
const windowStartedAt = clock(); const started = monotonic();
|
|
19
|
+
const source = host.transport === 'local' ? 'linux-proc-local' : 'linux-proc-ssh';
|
|
20
|
+
const provenance = { hostId: host.hostId, environmentId: host.environmentId, scope: 'whole-host', source,
|
|
21
|
+
intervalMs, timeoutMs, windowStartedAt, windowKind: 'collector-bracket' };
|
|
22
|
+
const check = () => { if (controller.signal.aborted) throw controller.signal.reason; };
|
|
23
|
+
try {
|
|
24
|
+
check();
|
|
25
|
+
const first = parseProc(await read(host, controller.signal));
|
|
26
|
+
check();
|
|
27
|
+
await pause(intervalMs, controller.signal);
|
|
28
|
+
check();
|
|
29
|
+
const last = parseProc(await read(host, controller.signal));
|
|
30
|
+
check();
|
|
31
|
+
const windowEndedAt = clock(); const durationMs = Math.floor(monotonic() - started);
|
|
32
|
+
if (durationMs >= timeoutMs) throw Error('timeout');
|
|
33
|
+
if (durationMs < intervalMs || windowEndedAt < windowStartedAt
|
|
34
|
+
|| windowEndedAt - windowStartedAt < durationMs
|
|
35
|
+
|| windowEndedAt - windowStartedAt > durationMs + 1000) throw Error('collector-clock');
|
|
36
|
+
return { ...provenance, status: 'ok', ...pressure(first, last), durationMs,
|
|
37
|
+
windowEndedAt, receivedAt: windowEndedAt };
|
|
38
|
+
} catch (error) {
|
|
39
|
+
const reason = controller.signal.aborted ? controller.signal.reason?.message : error.message;
|
|
40
|
+
return { ...provenance, status: ['timeout', 'cancelled', 'malformed', 'inconsistent-counters', 'collector-clock']
|
|
41
|
+
.includes(reason) ? reason : 'failed', durationMs: Math.max(0, Math.floor(monotonic() - started)), receivedAt: clock() };
|
|
42
|
+
} finally {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
signal?.removeEventListener('abort', cancel);
|
|
45
|
+
}
|
|
46
|
+
}));
|
|
47
|
+
return { schemaVersion: 1, telemetry };
|
|
48
|
+
}
|
|
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs';
|
|
|
3
3
|
const bindings = { codex: 'codex', claude: 'claudeAgent', grok: 'grok' };
|
|
4
4
|
const effortIds = { codex: 'reasoningEffort', claude: 'effort', grok: 'reasoningEffort' };
|
|
5
5
|
const nonempty = (value) => typeof value === 'string' && value.trim().length > 0;
|
|
6
|
+
const optionValues = (option) => option.values ?? option.options?.map((value) => value.id);
|
|
6
7
|
|
|
7
8
|
function argumentsFrom(args) {
|
|
8
9
|
const options = { excluded: [] };
|
|
@@ -37,9 +38,11 @@ function providerFrom(catalog, provider) {
|
|
|
37
38
|
}
|
|
38
39
|
const instance = matches[0];
|
|
39
40
|
if (!Array.isArray(instance.models) || instance.models.some((model) =>
|
|
40
|
-
!model || !nonempty(model.id) || !Array.isArray(model.options) || model.options.some((option) =>
|
|
41
|
+
!model || !nonempty(model.id) || !Array.isArray(model.options ?? []) || (model.options ?? []).some((option) =>
|
|
41
42
|
!option || !nonempty(option.id) || (option.options !== undefined &&
|
|
42
|
-
(!Array.isArray(option.options) || option.options.some((value) => !value || !nonempty(value.id))))
|
|
43
|
+
(!Array.isArray(option.options) || option.options.some((value) => !value || !nonempty(value.id))))
|
|
44
|
+
|| (option.values !== undefined && (!Array.isArray(option.values)
|
|
45
|
+
|| option.values.some((value) => typeof value !== 'boolean' && !nonempty(value))))))) {
|
|
43
46
|
throw new Error('malformed capabilities models or options');
|
|
44
47
|
}
|
|
45
48
|
return instance;
|
|
@@ -58,6 +61,7 @@ function modelFrom(models, { provider, model: pin, class: modelClass, excluded }
|
|
|
58
61
|
}
|
|
59
62
|
const model = models[0];
|
|
60
63
|
if (!model || excluded.includes(model.id)) throw new Error('missing first listed model');
|
|
64
|
+
if (!model.id.startsWith('grok-')) throw new Error(`first listed model ${model.id} is not grok-family`);
|
|
61
65
|
return model;
|
|
62
66
|
}
|
|
63
67
|
if (!['codex', 'claude'].includes(provider) || !/^[a-z]+$/.test(modelClass)) {
|
|
@@ -90,8 +94,8 @@ try {
|
|
|
90
94
|
if ((options.class === 'terra' || /^gpt-\d+(?:\.\d+)*-terra$/.test(model.id)) && effort !== 'high') {
|
|
91
95
|
throw new Error('Terra requires exactly high effort');
|
|
92
96
|
}
|
|
93
|
-
const effortOptions = model.options.filter((option) => option.id === effortIds[provider]);
|
|
94
|
-
if (effortOptions.length !== 1 || !effortOptions[0]
|
|
97
|
+
const effortOptions = (model.options ?? []).filter((option) => option.id === effortIds[provider]);
|
|
98
|
+
if (effortOptions.length !== 1 || !optionValues(effortOptions[0])?.includes(effort)
|
|
95
99
|
|| (provider === 'grok' && effort === 'max')) {
|
|
96
100
|
throw new Error(`unsupported effort ${effort} for ${provider}/${model.id}`);
|
|
97
101
|
}
|
|
@@ -43,6 +43,27 @@ Preserve unknown files, unmerged author work, ambiguous publication, and evidenc
|
|
|
43
43
|
Do not force removal, bulk-clean, edit a runtime database, or add a scheduler,
|
|
44
44
|
daemon or state machine. Report other projects' resources; never touch them.
|
|
45
45
|
|
|
46
|
+
## Wait registrations and owned wakes
|
|
47
|
+
|
|
48
|
+
Follow [Bounded recovery boundary](../axstack/references/t3-runtime.md#bounded-recovery-boundary)
|
|
49
|
+
and [Waiting-turn and closing records](../axstack/references/run-record.md#waiting-turn-and-closing-records)
|
|
50
|
+
when ending an owner condition. On completion/cancellation/handoff, reconcile
|
|
51
|
+
ownership and delete only run-owned schedule IDs with absence readback.
|
|
52
|
+
Stop owned watches and clear only identified owned duplicate scheduled messages,
|
|
53
|
+
recording exact IDs, scope, owner, lifetime and receipts. Preserve user messages
|
|
54
|
+
and shared persistent schedules. Run completion unregisters only its own
|
|
55
|
+
registration and preserves the shared service and other registrations.
|
|
56
|
+
Without separately authorized shared-service lifetime, recovery ends at run
|
|
57
|
+
close, empty scope or expiry, whichever comes first. Empty scope means no open
|
|
58
|
+
wait registrations, rather than no eligible action in this pass. After expiry,
|
|
59
|
+
perform cleanup-only with zero sends even if deletion failed.
|
|
60
|
+
Unknown deletion or queue state is a scoped cleanup finding, never a reason to
|
|
61
|
+
redo product work. Keep that resource ID and its cleanup owner until reconciled.
|
|
62
|
+
Verify Stop/restart/provider-failure queue behavior before promising a timer
|
|
63
|
+
wake; a timer cannot override a held queue. Use advertised interval/fixed-time/
|
|
64
|
+
webhook operations, never an invented dedicated one-shot trigger. Recovery owns
|
|
65
|
+
only its own resources; each Driver cleans its own watches, queues and schedules.
|
|
66
|
+
|
|
46
67
|
## Reconcile each candidate
|
|
47
68
|
|
|
48
69
|
Take a fresh native inventory and bind every candidate to its exact projectId, attempt key, taskId/childThreadId/childRunId
|
|
@@ -55,6 +55,8 @@ For substantive delegated or resumable work, use the shared
|
|
|
55
55
|
[run record](../axstack/references/run-record.md). Reconcile it on restart with
|
|
56
56
|
the approved scope, T3 threads/runs and forge state, exact revisions, tickets, and watches.
|
|
57
57
|
Reuse valid owners and authors; ambiguity holds a replacement writer.
|
|
58
|
+
When a logical Coordinator coordinates project drivers, follow
|
|
59
|
+
[Coordinating multiple drivers](../axstack/references/t3-runtime.md#coordinating-multiple-drivers).
|
|
58
60
|
|
|
59
61
|
At execution start, bind work to the T3 driver thread and one authoritative
|
|
60
62
|
dispatch attempt. Preserve the actual thread/task/run IDs and process completion
|
|
@@ -281,8 +283,10 @@ For each PR:
|
|
|
281
283
|
Load and follow [Round-3 step-back](references/step-back.md).
|
|
282
284
|
Load and follow [Review-round budgets](../axstack/references/run-record.md#review-round-budgets) before dispatching any repair from the step-back.
|
|
283
285
|
|
|
284
|
-
The T3 run watch reconciles
|
|
285
|
-
|
|
286
|
+
The T3 run watch reconciles unsettled launched-fallback Writer runs; native
|
|
287
|
+
completion delivers delegated receipts. Never status-check running delegate_task
|
|
288
|
+
children on scheduled wakes. The bounded forge check wait is the only other
|
|
289
|
+
implementation wait.
|
|
286
290
|
After verified readback of every own PR publication, the driver arms or joins
|
|
287
291
|
its maintain-mode chat-run watch under [Autopilot](../axstack/references/autopilot.md).
|
|
288
292
|
That watch owns the bound T3 schedule wake; its runtime sets the cadence.
|
|
@@ -290,10 +294,13 @@ A turn with unsettled launched threads must end only under the bound-watch rule
|
|
|
290
294
|
With settled threads, end a turn only when every required PR is `merge-ready` or `held`.
|
|
291
295
|
Follow [Route each wake](../axstack-watch/SKILL.md#4-route-each-wake).
|
|
292
296
|
Follow [Advised holds](../axstack/references/contracts.md#advised-holds) when raising eligible holds.
|
|
293
|
-
Raise user-decision and serious-risk holds in the T3 driver thread and
|
|
294
|
-
turn waiting on the decision
|
|
297
|
+
Raise user-decision and serious-risk holds in the owning T3 driver thread and
|
|
298
|
+
end the turn waiting on the decision when all remaining authorized work is held.
|
|
299
|
+
Continue independent authorized work under Autopilot's scoped-hold rules.
|
|
300
|
+
Serious risks are raised immediately.
|
|
295
301
|
After bounded safe recovery, raise a genuine blocked operation that still needs
|
|
296
|
-
the user in the T3 driver thread, and the driver ends its turn waiting on it
|
|
302
|
+
the user in the owning T3 driver thread, and the driver ends its turn waiting on it
|
|
303
|
+
when all remaining authorized work is held.
|
|
297
304
|
|
|
298
305
|
Merge-ready opens the merge boundary. Own PRs default to automatic merge under
|
|
299
306
|
[watch §5](../axstack-watch/SKILL.md#5-state-readiness-precisely).
|
|
@@ -137,7 +137,7 @@ This section applies to peer and authored PR modes.
|
|
|
137
137
|
For an `axstack-verify create` PR, the reviewer drives the candidate's recipe because the base has none.
|
|
138
138
|
The author never performs this review drive.
|
|
139
139
|
The reviewer drives each affected feature and records that drive as the `Final-head check`.
|
|
140
|
-
Delegate browser drives to `axstack-ui-verifier` with the feature file path.
|
|
140
|
+
Delegate browser drives to `axstack-ui-verifier` with the feature file path; device drives follow [UI verification](../../axstack/references/ui-verification.md#android-device-and-webpwa-dual-run)'s dual-run trigger.
|
|
141
141
|
Running the applicable surface counts as spin-up; a CLI run qualifies.
|
|
142
142
|
Use loopback and isolated instances under [axstack-verify](../../axstack-verify/SKILL.md#isolate-every-attempt).
|
|
143
143
|
|