agentxchain 2.47.0 → 2.49.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/bin/agentxchain.js +42 -0
- package/package.json +1 -1
- package/src/commands/events.js +150 -0
- package/src/commands/run.js +97 -69
- package/src/commands/schedule.js +265 -0
- package/src/lib/export.js +4 -0
- package/src/lib/governed-state.js +107 -0
- package/src/lib/normalized-config.js +80 -0
- package/src/lib/repo-observer.js +3 -0
- package/src/lib/run-events.js +117 -0
- package/src/lib/run-schedule.js +160 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { loadProjectContext } from '../lib/config.js';
|
|
3
|
+
import {
|
|
4
|
+
SCHEDULE_STATE_PATH,
|
|
5
|
+
listSchedules,
|
|
6
|
+
updateScheduleState,
|
|
7
|
+
evaluateScheduleLaunchEligibility,
|
|
8
|
+
} from '../lib/run-schedule.js';
|
|
9
|
+
import { executeGovernedRun } from './run.js';
|
|
10
|
+
|
|
11
|
+
function loadScheduleContext() {
|
|
12
|
+
const context = loadProjectContext();
|
|
13
|
+
if (!context) {
|
|
14
|
+
console.error(chalk.red('No agentxchain.json found. Run `agentxchain init` first.'));
|
|
15
|
+
process.exitCode = 1;
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
if (context.config.protocol_mode !== 'governed') {
|
|
19
|
+
console.error(chalk.red('The schedule command is only available for governed projects.'));
|
|
20
|
+
process.exitCode = 1;
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
return context;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function resolveScheduleEntries(context, scheduleId, at) {
|
|
27
|
+
const entries = listSchedules(context.root, context.config, { at });
|
|
28
|
+
if (!scheduleId) {
|
|
29
|
+
return { ok: true, entries };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const matched = entries.find((entry) => entry.id === scheduleId);
|
|
33
|
+
if (!matched) {
|
|
34
|
+
return { ok: false, error: `Unknown schedule: ${scheduleId}` };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { ok: true, entries: [matched] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function printScheduleTable(entries) {
|
|
41
|
+
if (entries.length === 0) {
|
|
42
|
+
console.log(chalk.dim('No schedules configured.'));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const header = [
|
|
47
|
+
pad('Schedule', 24),
|
|
48
|
+
pad('Enabled', 8),
|
|
49
|
+
pad('Every', 8),
|
|
50
|
+
pad('Due', 6),
|
|
51
|
+
pad('Next Due', 24),
|
|
52
|
+
pad('Last Status', 18),
|
|
53
|
+
pad('Last Run', 14),
|
|
54
|
+
].join(' ');
|
|
55
|
+
console.log(chalk.bold(header));
|
|
56
|
+
console.log(chalk.dim('─'.repeat(header.length)));
|
|
57
|
+
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
console.log([
|
|
60
|
+
pad(entry.id, 24),
|
|
61
|
+
pad(entry.enabled ? 'yes' : 'no', 8),
|
|
62
|
+
pad(`${entry.every_minutes}m`, 8),
|
|
63
|
+
pad(entry.due ? 'yes' : 'no', 6),
|
|
64
|
+
pad(entry.next_due_at || '—', 24),
|
|
65
|
+
pad(entry.last_status || entry.last_skip_reason || '—', 18),
|
|
66
|
+
pad((entry.last_run_id || '—').slice(0, 12), 14),
|
|
67
|
+
].join(' '));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function pad(value, width) {
|
|
72
|
+
return String(value || '').padEnd(width);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function buildScheduleProvenance(entry) {
|
|
76
|
+
return {
|
|
77
|
+
trigger: 'schedule',
|
|
78
|
+
created_by: 'operator',
|
|
79
|
+
trigger_reason: entry.trigger_reason || `schedule:${entry.id}`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function runDueSchedules(context, opts = {}) {
|
|
84
|
+
const resolved = resolveScheduleEntries(context, opts.schedule, opts.at);
|
|
85
|
+
if (!resolved.ok) {
|
|
86
|
+
return { ok: false, exitCode: 1, error: resolved.error, results: [] };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const nowIso = opts.at || new Date().toISOString();
|
|
90
|
+
const results = [];
|
|
91
|
+
|
|
92
|
+
for (const entry of resolved.entries) {
|
|
93
|
+
if (!entry.enabled) {
|
|
94
|
+
results.push({ id: entry.id, action: 'disabled' });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (!entry.due) {
|
|
98
|
+
results.push({ id: entry.id, action: 'not_due', next_due_at: entry.next_due_at });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const eligibility = evaluateScheduleLaunchEligibility(context.root, context.config);
|
|
103
|
+
if (!eligibility.ok) {
|
|
104
|
+
updateScheduleState(context.root, context.config, entry.id, (record) => ({
|
|
105
|
+
...record,
|
|
106
|
+
last_skip_at: nowIso,
|
|
107
|
+
last_skip_reason: eligibility.reason,
|
|
108
|
+
}));
|
|
109
|
+
results.push({
|
|
110
|
+
id: entry.id,
|
|
111
|
+
action: 'skipped',
|
|
112
|
+
reason: eligibility.reason,
|
|
113
|
+
project_status: eligibility.status,
|
|
114
|
+
});
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!opts.json) {
|
|
119
|
+
console.log(chalk.cyan(`Schedule due: ${entry.id}`));
|
|
120
|
+
}
|
|
121
|
+
const execution = await executeGovernedRun(context, {
|
|
122
|
+
provenance: buildScheduleProvenance(entry),
|
|
123
|
+
maxTurns: entry.max_turns,
|
|
124
|
+
autoApprove: entry.auto_approve,
|
|
125
|
+
role: entry.initial_role || undefined,
|
|
126
|
+
report: true,
|
|
127
|
+
allowBlockedRestart: false,
|
|
128
|
+
requireFreshStart: true,
|
|
129
|
+
allowedFreshStatuses: ['idle', 'completed'],
|
|
130
|
+
log: opts.json ? () => {} : console.log,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (execution.skipped) {
|
|
134
|
+
updateScheduleState(context.root, context.config, entry.id, (record) => ({
|
|
135
|
+
...record,
|
|
136
|
+
last_skip_at: nowIso,
|
|
137
|
+
last_skip_reason: execution.skipReason,
|
|
138
|
+
}));
|
|
139
|
+
results.push({
|
|
140
|
+
id: entry.id,
|
|
141
|
+
action: 'skipped',
|
|
142
|
+
reason: execution.skipReason,
|
|
143
|
+
});
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const runId = execution.result?.state?.run_id || null;
|
|
148
|
+
const startedAt = execution.result?.state?.created_at || nowIso;
|
|
149
|
+
updateScheduleState(context.root, context.config, entry.id, (record) => ({
|
|
150
|
+
...record,
|
|
151
|
+
last_started_at: startedAt,
|
|
152
|
+
last_finished_at: new Date().toISOString(),
|
|
153
|
+
last_run_id: runId,
|
|
154
|
+
last_status: execution.result?.stop_reason || (execution.exitCode === 0 ? 'completed' : 'launch_failed'),
|
|
155
|
+
last_skip_at: null,
|
|
156
|
+
last_skip_reason: null,
|
|
157
|
+
}));
|
|
158
|
+
results.push({
|
|
159
|
+
id: entry.id,
|
|
160
|
+
action: 'ran',
|
|
161
|
+
run_id: runId,
|
|
162
|
+
stop_reason: execution.result?.stop_reason || null,
|
|
163
|
+
exit_code: execution.exitCode,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
if (execution.exitCode !== 0) {
|
|
167
|
+
return { ok: false, exitCode: execution.exitCode, results };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { ok: true, exitCode: 0, results };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function scheduleListCommand(opts) {
|
|
175
|
+
const context = loadScheduleContext();
|
|
176
|
+
if (!context) return;
|
|
177
|
+
|
|
178
|
+
const resolved = resolveScheduleEntries(context, opts.schedule, opts.at);
|
|
179
|
+
if (!resolved.ok) {
|
|
180
|
+
console.error(chalk.red(resolved.error));
|
|
181
|
+
process.exitCode = 1;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (opts.json) {
|
|
186
|
+
console.log(JSON.stringify({
|
|
187
|
+
schedules: resolved.entries,
|
|
188
|
+
state_file: SCHEDULE_STATE_PATH,
|
|
189
|
+
}, null, 2));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
printScheduleTable(resolved.entries);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function scheduleRunDueCommand(opts) {
|
|
197
|
+
const context = loadScheduleContext();
|
|
198
|
+
if (!context) return;
|
|
199
|
+
|
|
200
|
+
const result = await runDueSchedules(context, opts);
|
|
201
|
+
if (opts.json) {
|
|
202
|
+
console.log(JSON.stringify(result, null, 2));
|
|
203
|
+
} else if (!result.ok) {
|
|
204
|
+
console.error(chalk.red(result.error || 'Scheduled run failed'));
|
|
205
|
+
} else if (result.results.length === 0) {
|
|
206
|
+
console.log(chalk.dim('No schedules configured.'));
|
|
207
|
+
} else {
|
|
208
|
+
for (const entry of result.results) {
|
|
209
|
+
if (entry.action === 'ran') {
|
|
210
|
+
console.log(chalk.green(`Schedule ran: ${entry.id} (${entry.run_id || 'no run id'})`));
|
|
211
|
+
} else if (entry.action === 'skipped') {
|
|
212
|
+
console.log(chalk.yellow(`Schedule skipped: ${entry.id} (${entry.reason})`));
|
|
213
|
+
} else if (entry.action === 'not_due') {
|
|
214
|
+
console.log(chalk.dim(`Schedule not due: ${entry.id}`));
|
|
215
|
+
} else if (entry.action === 'disabled') {
|
|
216
|
+
console.log(chalk.dim(`Schedule disabled: ${entry.id}`));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
process.exitCode = result.exitCode;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function scheduleDaemonCommand(opts) {
|
|
225
|
+
const context = loadScheduleContext();
|
|
226
|
+
if (!context) return;
|
|
227
|
+
|
|
228
|
+
const pollSeconds = Number.parseInt(opts.pollSeconds ?? '60', 10);
|
|
229
|
+
const maxCycles = opts.maxCycles != null ? Number.parseInt(opts.maxCycles, 10) : null;
|
|
230
|
+
if (!Number.isInteger(pollSeconds) || pollSeconds < 1) {
|
|
231
|
+
console.error(chalk.red('--poll-seconds must be an integer >= 1'));
|
|
232
|
+
process.exitCode = 1;
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (maxCycles !== null && (!Number.isInteger(maxCycles) || maxCycles < 1)) {
|
|
236
|
+
console.error(chalk.red('--max-cycles must be an integer >= 1'));
|
|
237
|
+
process.exitCode = 1;
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let cycle = 0;
|
|
242
|
+
if (!opts.json) {
|
|
243
|
+
console.log(chalk.bold('AgentXchain Schedule Daemon'));
|
|
244
|
+
console.log(chalk.dim(` Poll: ${pollSeconds}s`));
|
|
245
|
+
console.log(chalk.dim(` State: ${SCHEDULE_STATE_PATH}`));
|
|
246
|
+
console.log('');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
while (true) {
|
|
250
|
+
cycle += 1;
|
|
251
|
+
const result = await runDueSchedules(context, opts);
|
|
252
|
+
if (opts.json) {
|
|
253
|
+
console.log(JSON.stringify({ cycle, ...result }));
|
|
254
|
+
}
|
|
255
|
+
if (!result.ok) {
|
|
256
|
+
process.exitCode = result.exitCode;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (maxCycles !== null && cycle >= maxCycles) {
|
|
260
|
+
process.exitCode = 0;
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
await new Promise((resolve) => setTimeout(resolve, pollSeconds * 1000));
|
|
264
|
+
}
|
|
265
|
+
}
|
package/src/lib/export.js
CHANGED
|
@@ -31,6 +31,8 @@ export const RUN_EXPORT_INCLUDED_ROOTS = [
|
|
|
31
31
|
'.agentxchain/hook-annotations.jsonl',
|
|
32
32
|
'.agentxchain/notification-audit.jsonl',
|
|
33
33
|
'.agentxchain/run-history.jsonl',
|
|
34
|
+
'.agentxchain/events.jsonl',
|
|
35
|
+
'.agentxchain/schedule-state.json',
|
|
34
36
|
'.agentxchain/dispatch',
|
|
35
37
|
'.agentxchain/staging',
|
|
36
38
|
'.agentxchain/transactions/accept',
|
|
@@ -53,6 +55,8 @@ export const RUN_RESTORE_ROOTS = [
|
|
|
53
55
|
'.agentxchain/hook-annotations.jsonl',
|
|
54
56
|
'.agentxchain/notification-audit.jsonl',
|
|
55
57
|
'.agentxchain/run-history.jsonl',
|
|
58
|
+
'.agentxchain/events.jsonl',
|
|
59
|
+
'.agentxchain/schedule-state.json',
|
|
56
60
|
'.agentxchain/dispatch',
|
|
57
61
|
'.agentxchain/staging',
|
|
58
62
|
'.agentxchain/transactions/accept',
|
|
@@ -40,6 +40,7 @@ import { getMaxConcurrentTurns } from './normalized-config.js';
|
|
|
40
40
|
import { getTurnStagingResultPath, getTurnStagingDir, getDispatchTurnDir, getReviewArtifactPath } from './turn-paths.js';
|
|
41
41
|
import { runHooks } from './hook-runner.js';
|
|
42
42
|
import { emitNotifications } from './notification-runner.js';
|
|
43
|
+
import { emitRunEvent } from './run-events.js';
|
|
43
44
|
import { writeSessionCheckpoint } from './session-checkpoint.js';
|
|
44
45
|
import { recordRunHistory } from './run-history.js';
|
|
45
46
|
import { buildDefaultRunProvenance } from './run-provenance.js';
|
|
@@ -1751,6 +1752,12 @@ export function raiseOperatorEscalation(root, config, details) {
|
|
|
1751
1752
|
detail,
|
|
1752
1753
|
recovery_action: recoveryAction,
|
|
1753
1754
|
}, targetTurn);
|
|
1755
|
+
emitRunEvent(root, 'escalation_raised', {
|
|
1756
|
+
run_id: blocked.state.run_id,
|
|
1757
|
+
phase: blocked.state.phase,
|
|
1758
|
+
status: 'blocked',
|
|
1759
|
+
payload: { source: 'operator', reason },
|
|
1760
|
+
});
|
|
1754
1761
|
|
|
1755
1762
|
return {
|
|
1756
1763
|
ok: true,
|
|
@@ -1794,6 +1801,12 @@ export function reactivateGovernedRun(root, state, details = {}) {
|
|
|
1794
1801
|
resolved_via: details.via || 'unknown',
|
|
1795
1802
|
previous_escalation: state.escalation || null,
|
|
1796
1803
|
}, state.escalation?.from_turn_id ? getActiveTurns(state)[state.escalation.from_turn_id] || getActiveTurn(state) : getActiveTurn(state));
|
|
1804
|
+
emitRunEvent(details.root || root, 'escalation_resolved', {
|
|
1805
|
+
run_id: nextState.run_id,
|
|
1806
|
+
phase: nextState.phase,
|
|
1807
|
+
status: nextState.status,
|
|
1808
|
+
payload: { resolved_via: details.via || 'unknown' },
|
|
1809
|
+
});
|
|
1797
1810
|
}
|
|
1798
1811
|
|
|
1799
1812
|
return { ok: true, state: attachLegacyCurrentTurnAlias(nextState) };
|
|
@@ -1846,6 +1859,12 @@ export function initializeGovernedRun(root, config, options = {}) {
|
|
|
1846
1859
|
};
|
|
1847
1860
|
|
|
1848
1861
|
writeState(root, updatedState);
|
|
1862
|
+
emitRunEvent(root, 'run_started', {
|
|
1863
|
+
run_id: runId,
|
|
1864
|
+
phase: updatedState.phase,
|
|
1865
|
+
status: 'active',
|
|
1866
|
+
payload: { provenance: provenance || {} },
|
|
1867
|
+
});
|
|
1849
1868
|
return { ok: true, state: attachLegacyCurrentTurnAlias(updatedState) };
|
|
1850
1869
|
}
|
|
1851
1870
|
|
|
@@ -2051,6 +2070,13 @@ export function assignGovernedTurn(root, config, roleId) {
|
|
|
2051
2070
|
|
|
2052
2071
|
writeState(root, updatedState);
|
|
2053
2072
|
|
|
2073
|
+
emitRunEvent(root, 'turn_dispatched', {
|
|
2074
|
+
run_id: updatedState.run_id,
|
|
2075
|
+
phase: updatedState.phase,
|
|
2076
|
+
status: updatedState.status,
|
|
2077
|
+
turn: { turn_id: turnId, role_id: roleId },
|
|
2078
|
+
});
|
|
2079
|
+
|
|
2054
2080
|
// Session checkpoint — non-fatal, written after every successful turn assignment
|
|
2055
2081
|
writeSessionCheckpoint(root, updatedState, 'turn_assigned', {
|
|
2056
2082
|
role: roleId,
|
|
@@ -3027,6 +3053,14 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3027
3053
|
}
|
|
3028
3054
|
}
|
|
3029
3055
|
|
|
3056
|
+
// Emit turn_accepted event to local log.
|
|
3057
|
+
emitRunEvent(root, 'turn_accepted', {
|
|
3058
|
+
run_id: updatedState.run_id,
|
|
3059
|
+
phase: updatedState.phase,
|
|
3060
|
+
status: updatedState.status,
|
|
3061
|
+
turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
|
|
3062
|
+
});
|
|
3063
|
+
|
|
3030
3064
|
if (updatedState.status === 'blocked') {
|
|
3031
3065
|
// DEC-RHTR-SPEC: Record blocked outcome in cross-run history (non-fatal)
|
|
3032
3066
|
// Covers needs_human, budget:exhausted, and any other non-hook blocked states
|
|
@@ -3037,6 +3071,13 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3037
3071
|
blockedOn: updatedState.blocked_on,
|
|
3038
3072
|
recovery: updatedState.blocked_reason?.recovery || null,
|
|
3039
3073
|
}, currentTurn);
|
|
3074
|
+
emitRunEvent(root, 'run_blocked', {
|
|
3075
|
+
run_id: updatedState.run_id,
|
|
3076
|
+
phase: updatedState.phase,
|
|
3077
|
+
status: 'blocked',
|
|
3078
|
+
turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
|
|
3079
|
+
payload: { category: updatedState.blocked_reason?.category || 'needs_human' },
|
|
3080
|
+
});
|
|
3040
3081
|
}
|
|
3041
3082
|
|
|
3042
3083
|
if (updatedState.pending_phase_transition) {
|
|
@@ -3046,6 +3087,16 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3046
3087
|
gate: updatedState.pending_phase_transition.gate,
|
|
3047
3088
|
requested_by_turn: updatedState.pending_phase_transition.requested_by_turn,
|
|
3048
3089
|
}, currentTurn);
|
|
3090
|
+
emitRunEvent(root, 'gate_pending', {
|
|
3091
|
+
run_id: updatedState.run_id,
|
|
3092
|
+
phase: updatedState.phase,
|
|
3093
|
+
status: updatedState.status,
|
|
3094
|
+
payload: {
|
|
3095
|
+
gate_type: 'phase_transition',
|
|
3096
|
+
from: updatedState.pending_phase_transition.from,
|
|
3097
|
+
to: updatedState.pending_phase_transition.to,
|
|
3098
|
+
},
|
|
3099
|
+
});
|
|
3049
3100
|
}
|
|
3050
3101
|
|
|
3051
3102
|
if (updatedState.pending_run_completion) {
|
|
@@ -3054,6 +3105,12 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3054
3105
|
requested_by_turn: updatedState.pending_run_completion.requested_by_turn,
|
|
3055
3106
|
requested_at: updatedState.pending_run_completion.requested_at,
|
|
3056
3107
|
}, currentTurn);
|
|
3108
|
+
emitRunEvent(root, 'gate_pending', {
|
|
3109
|
+
run_id: updatedState.run_id,
|
|
3110
|
+
phase: updatedState.phase,
|
|
3111
|
+
status: updatedState.status,
|
|
3112
|
+
payload: { gate_type: 'run_completion' },
|
|
3113
|
+
});
|
|
3057
3114
|
}
|
|
3058
3115
|
|
|
3059
3116
|
if (updatedState.status === 'completed') {
|
|
@@ -3062,6 +3119,12 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3062
3119
|
completed_via: completionResult?.action === 'complete' ? 'accept_turn' : 'accept_turn_direct',
|
|
3063
3120
|
requested_by_turn: completionResult?.requested_by_turn || turnResult.turn_id,
|
|
3064
3121
|
}, currentTurn);
|
|
3122
|
+
emitRunEvent(root, 'run_completed', {
|
|
3123
|
+
run_id: updatedState.run_id,
|
|
3124
|
+
phase: updatedState.phase,
|
|
3125
|
+
status: 'completed',
|
|
3126
|
+
payload: { completed_at: updatedState.completed_at || now },
|
|
3127
|
+
});
|
|
3065
3128
|
}
|
|
3066
3129
|
|
|
3067
3130
|
// Session checkpoint — non-fatal, written after every successful acceptance
|
|
@@ -3211,6 +3274,13 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
|
|
|
3211
3274
|
};
|
|
3212
3275
|
|
|
3213
3276
|
writeState(root, updatedState);
|
|
3277
|
+
emitRunEvent(root, 'turn_rejected', {
|
|
3278
|
+
run_id: updatedState.run_id,
|
|
3279
|
+
phase: updatedState.phase,
|
|
3280
|
+
status: updatedState.status,
|
|
3281
|
+
turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
|
|
3282
|
+
payload: { attempt: currentAttempt, retrying: true },
|
|
3283
|
+
});
|
|
3214
3284
|
return {
|
|
3215
3285
|
ok: true,
|
|
3216
3286
|
state: attachLegacyCurrentTurnAlias(updatedState),
|
|
@@ -3271,6 +3341,19 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
|
|
|
3271
3341
|
blockedOn: updatedState.blocked_on,
|
|
3272
3342
|
recovery: updatedState.blocked_reason?.recovery || null,
|
|
3273
3343
|
}, updatedState.active_turns[currentTurn.turn_id]);
|
|
3344
|
+
emitRunEvent(root, 'turn_rejected', {
|
|
3345
|
+
run_id: updatedState.run_id,
|
|
3346
|
+
phase: updatedState.phase,
|
|
3347
|
+
status: 'blocked',
|
|
3348
|
+
turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
|
|
3349
|
+
payload: { attempt: currentAttempt, retrying: false, escalated: true },
|
|
3350
|
+
});
|
|
3351
|
+
emitRunEvent(root, 'run_blocked', {
|
|
3352
|
+
run_id: updatedState.run_id,
|
|
3353
|
+
phase: updatedState.phase,
|
|
3354
|
+
status: 'blocked',
|
|
3355
|
+
payload: { category: 'retries_exhausted' },
|
|
3356
|
+
});
|
|
3274
3357
|
|
|
3275
3358
|
// Fire on_escalation hooks (advisory-only) after blocked state is persisted.
|
|
3276
3359
|
const hooksConfig = config?.hooks || {};
|
|
@@ -3382,6 +3465,18 @@ export function approvePhaseTransition(root, config) {
|
|
|
3382
3465
|
};
|
|
3383
3466
|
|
|
3384
3467
|
writeState(root, updatedState);
|
|
3468
|
+
emitRunEvent(root, 'gate_approved', {
|
|
3469
|
+
run_id: updatedState.run_id,
|
|
3470
|
+
phase: updatedState.phase,
|
|
3471
|
+
status: 'active',
|
|
3472
|
+
payload: { gate_type: 'phase_transition', from: transition.from, to: transition.to },
|
|
3473
|
+
});
|
|
3474
|
+
emitRunEvent(root, 'phase_entered', {
|
|
3475
|
+
run_id: updatedState.run_id,
|
|
3476
|
+
phase: updatedState.phase,
|
|
3477
|
+
status: 'active',
|
|
3478
|
+
payload: { from: transition.from },
|
|
3479
|
+
});
|
|
3385
3480
|
|
|
3386
3481
|
// Session checkpoint — non-fatal
|
|
3387
3482
|
writeSessionCheckpoint(root, updatedState, 'phase_approved');
|
|
@@ -3484,6 +3579,18 @@ export function approveRunCompletion(root, config) {
|
|
|
3484
3579
|
gate: completion.gate,
|
|
3485
3580
|
requested_by_turn: completion.requested_by_turn || null,
|
|
3486
3581
|
}, completion.requested_by_turn ? getActiveTurns(state)[completion.requested_by_turn] || null : null);
|
|
3582
|
+
emitRunEvent(root, 'gate_approved', {
|
|
3583
|
+
run_id: updatedState.run_id,
|
|
3584
|
+
phase: updatedState.phase,
|
|
3585
|
+
status: 'completed',
|
|
3586
|
+
payload: { gate_type: 'run_completion' },
|
|
3587
|
+
});
|
|
3588
|
+
emitRunEvent(root, 'run_completed', {
|
|
3589
|
+
run_id: updatedState.run_id,
|
|
3590
|
+
phase: updatedState.phase,
|
|
3591
|
+
status: 'completed',
|
|
3592
|
+
payload: { completed_at: updatedState.completed_at },
|
|
3593
|
+
});
|
|
3487
3594
|
|
|
3488
3595
|
// Session checkpoint — non-fatal
|
|
3489
3596
|
writeSessionCheckpoint(root, updatedState, 'run_completed');
|
|
@@ -34,6 +34,7 @@ const DEFAULT_PHASES = ['planning', 'implementation', 'qa'];
|
|
|
34
34
|
export { DEFAULT_PHASES };
|
|
35
35
|
const VALID_PHASE_NAME = /^[a-z][a-z0-9_-]*$/;
|
|
36
36
|
const VALID_SEMANTIC_IDS = ['pm_signoff', 'system_spec', 'implementation_notes', 'acceptance_matrix', 'ship_verdict', 'release_notes', 'section_check'];
|
|
37
|
+
const VALID_SCHEDULE_ID = /^[a-z0-9_-]+$/;
|
|
37
38
|
|
|
38
39
|
const VALID_API_PROXY_RETRY_JITTER = ['none', 'full'];
|
|
39
40
|
const VALID_API_PROXY_RETRY_CLASSES = [
|
|
@@ -508,6 +509,12 @@ export function validateV4Config(data, projectRoot) {
|
|
|
508
509
|
errors.push(...notificationValidation.errors);
|
|
509
510
|
}
|
|
510
511
|
|
|
512
|
+
// Schedules (optional but validated if present)
|
|
513
|
+
if (data.schedules !== undefined) {
|
|
514
|
+
const scheduleValidation = validateSchedulesConfig(data.schedules, data.roles);
|
|
515
|
+
errors.push(...scheduleValidation.errors);
|
|
516
|
+
}
|
|
517
|
+
|
|
511
518
|
// Workflow Kit (optional but validated if present)
|
|
512
519
|
if (data.workflow_kit !== undefined) {
|
|
513
520
|
const wkValidation = validateWorkflowKitConfig(data.workflow_kit, data.routing, data.roles);
|
|
@@ -534,6 +541,57 @@ export function validateV4Config(data, projectRoot) {
|
|
|
534
541
|
return { ok: errors.length === 0, errors };
|
|
535
542
|
}
|
|
536
543
|
|
|
544
|
+
export function validateSchedulesConfig(schedules, roles) {
|
|
545
|
+
const errors = [];
|
|
546
|
+
|
|
547
|
+
if (!schedules || typeof schedules !== 'object' || Array.isArray(schedules)) {
|
|
548
|
+
errors.push('schedules must be an object');
|
|
549
|
+
return { ok: false, errors };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
for (const [scheduleId, schedule] of Object.entries(schedules)) {
|
|
553
|
+
if (!VALID_SCHEDULE_ID.test(scheduleId)) {
|
|
554
|
+
errors.push(`Schedule "${scheduleId}" must use lowercase alphanumeric, underscore, or hyphen characters only`);
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (!schedule || typeof schedule !== 'object' || Array.isArray(schedule)) {
|
|
559
|
+
errors.push(`Schedule "${scheduleId}" must be an object`);
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (!Number.isInteger(schedule.every_minutes) || schedule.every_minutes < 1) {
|
|
564
|
+
errors.push(`Schedule "${scheduleId}": every_minutes must be an integer >= 1`);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if ('enabled' in schedule && typeof schedule.enabled !== 'boolean') {
|
|
568
|
+
errors.push(`Schedule "${scheduleId}": enabled must be a boolean`);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if ('auto_approve' in schedule && typeof schedule.auto_approve !== 'boolean') {
|
|
572
|
+
errors.push(`Schedule "${scheduleId}": auto_approve must be a boolean`);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if ('max_turns' in schedule && (!Number.isInteger(schedule.max_turns) || schedule.max_turns < 1)) {
|
|
576
|
+
errors.push(`Schedule "${scheduleId}": max_turns must be an integer >= 1`);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if ('trigger_reason' in schedule && (typeof schedule.trigger_reason !== 'string' || !schedule.trigger_reason.trim())) {
|
|
580
|
+
errors.push(`Schedule "${scheduleId}": trigger_reason must be a non-empty string when provided`);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if ('initial_role' in schedule) {
|
|
584
|
+
if (typeof schedule.initial_role !== 'string' || !schedule.initial_role.trim()) {
|
|
585
|
+
errors.push(`Schedule "${scheduleId}": initial_role must be a non-empty string when provided`);
|
|
586
|
+
} else if (roles && !roles[schedule.initial_role]) {
|
|
587
|
+
errors.push(`Schedule "${scheduleId}": initial_role "${schedule.initial_role}" is not a defined role`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return { ok: errors.length === 0, errors };
|
|
593
|
+
}
|
|
594
|
+
|
|
537
595
|
/**
|
|
538
596
|
* Validate the workflow_kit config section.
|
|
539
597
|
* Returns { ok, errors, warnings }.
|
|
@@ -850,6 +908,7 @@ export function normalizeV3(raw) {
|
|
|
850
908
|
gates: {},
|
|
851
909
|
hooks: {},
|
|
852
910
|
notifications: {},
|
|
911
|
+
schedules: {},
|
|
853
912
|
budget: null,
|
|
854
913
|
policies: [],
|
|
855
914
|
approval_policy: null,
|
|
@@ -917,6 +976,7 @@ export function normalizeV4(raw) {
|
|
|
917
976
|
gates: raw.gates || {},
|
|
918
977
|
hooks: raw.hooks || {},
|
|
919
978
|
notifications: raw.notifications || {},
|
|
979
|
+
schedules: normalizeSchedules(raw.schedules),
|
|
920
980
|
budget: raw.budget || null,
|
|
921
981
|
policies: normalizePolicies(raw.policies),
|
|
922
982
|
approval_policy: raw.approval_policy || null,
|
|
@@ -949,6 +1009,26 @@ export function normalizeV4(raw) {
|
|
|
949
1009
|
};
|
|
950
1010
|
}
|
|
951
1011
|
|
|
1012
|
+
function normalizeSchedules(rawSchedules) {
|
|
1013
|
+
if (!rawSchedules || typeof rawSchedules !== 'object' || Array.isArray(rawSchedules)) {
|
|
1014
|
+
return {};
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
return Object.fromEntries(
|
|
1018
|
+
Object.entries(rawSchedules).map(([scheduleId, schedule]) => [
|
|
1019
|
+
scheduleId,
|
|
1020
|
+
{
|
|
1021
|
+
enabled: schedule?.enabled !== false,
|
|
1022
|
+
every_minutes: schedule?.every_minutes,
|
|
1023
|
+
auto_approve: schedule?.auto_approve !== false,
|
|
1024
|
+
max_turns: schedule?.max_turns ?? 50,
|
|
1025
|
+
initial_role: schedule?.initial_role || null,
|
|
1026
|
+
trigger_reason: schedule?.trigger_reason?.trim() || `schedule:${scheduleId}`,
|
|
1027
|
+
},
|
|
1028
|
+
]),
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
952
1032
|
/**
|
|
953
1033
|
* Load and normalize a config from raw JSON.
|
|
954
1034
|
* Returns { ok, normalized, errors, version }.
|
package/src/lib/repo-observer.js
CHANGED
|
@@ -41,6 +41,9 @@ const ORCHESTRATOR_STATE_FILES = [
|
|
|
41
41
|
'.agentxchain/hook-audit.jsonl',
|
|
42
42
|
'.agentxchain/hook-annotations.jsonl',
|
|
43
43
|
'.agentxchain/run-history.jsonl',
|
|
44
|
+
'.agentxchain/events.jsonl',
|
|
45
|
+
'.agentxchain/notification-audit.jsonl',
|
|
46
|
+
'.agentxchain/schedule-state.json',
|
|
44
47
|
'TALK.md',
|
|
45
48
|
];
|
|
46
49
|
|