agentxchain 2.49.0 → 2.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.
@@ -107,7 +107,7 @@ import { intakeStatusCommand } from '../src/commands/intake-status.js';
107
107
  import { demoCommand } from '../src/commands/demo.js';
108
108
  import { historyCommand } from '../src/commands/history.js';
109
109
  import { eventsCommand } from '../src/commands/events.js';
110
- import { scheduleDaemonCommand, scheduleListCommand, scheduleRunDueCommand } from '../src/commands/schedule.js';
110
+ import { scheduleDaemonCommand, scheduleListCommand, scheduleRunDueCommand, scheduleStatusCommand } from '../src/commands/schedule.js';
111
111
 
112
112
  const __dirname = dirname(fileURLToPath(import.meta.url));
113
113
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
@@ -288,6 +288,12 @@ scheduleCmd
288
288
  .option('-j, --json', 'Output as JSON')
289
289
  .action(scheduleDaemonCommand);
290
290
 
291
+ scheduleCmd
292
+ .command('status')
293
+ .description('Show daemon health: running, stale, not_running, or never_started')
294
+ .option('-j, --json', 'Output as JSON')
295
+ .action(scheduleStatusCommand);
296
+
291
297
  program
292
298
  .command('history')
293
299
  .description('Show cross-run history of governed runs in this project')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.49.0",
3
+ "version": "2.50.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,6 +27,18 @@ formula_sha() {
27
27
  grep -E '^\s*sha256\s+"' "$formula_path" | sed 's/.*sha256 *"\([a-f0-9]*\)".*/\1/' || true
28
28
  }
29
29
 
30
+ canonical_tap_matches_target() {
31
+ local formula_path="$1"
32
+ local expected_url="$2"
33
+ local expected_sha="$3"
34
+ [[ -f "$formula_path" ]] || return 1
35
+ local remote_url
36
+ local remote_sha
37
+ remote_url="$(formula_url "$formula_path")"
38
+ remote_sha="$(formula_sha "$formula_path")"
39
+ [[ "$remote_url" == "$expected_url" && "$remote_sha" == "$expected_sha" ]]
40
+ }
41
+
30
42
  usage() {
31
43
  echo "Usage: bash scripts/sync-homebrew.sh --target-version <semver> [--push-tap] [--dry-run]" >&2
32
44
  }
@@ -207,10 +219,21 @@ if $PUSH_TAP; then
207
219
  git add Formula/agentxchain.rb
208
220
  git commit -m "agentxchain ${TARGET_VERSION}"
209
221
  if ! git push origin HEAD:main; then
210
- echo "FAIL: could not push to ${CANONICAL_TAP_REPO}" >&2
211
- exit 1
222
+ echo " Push rejected by ${CANONICAL_TAP_REPO}; verifying remote state..."
223
+ git fetch origin main >/dev/null 2>&1 || true
224
+ REMOTE_FORMULA="$(mktemp "${TMPDIR:-/tmp}/homebrew-tap-remote-formula.XXXXXX")"
225
+ if git show origin/main:Formula/agentxchain.rb >"$REMOTE_FORMULA" 2>/dev/null \
226
+ && canonical_tap_matches_target "$REMOTE_FORMULA" "$TARBALL_URL" "$TARBALL_SHA"; then
227
+ rm -f "$REMOTE_FORMULA"
228
+ echo " Canonical tap already matches target after push rejection — treating sync as complete."
229
+ else
230
+ rm -f "$REMOTE_FORMULA"
231
+ echo "FAIL: could not push to ${CANONICAL_TAP_REPO} and remote tap does not match target artifact" >&2
232
+ exit 1
233
+ fi
234
+ else
235
+ echo " Pushed to ${CANONICAL_TAP_REPO}"
212
236
  fi
213
- echo " Pushed to ${CANONICAL_TAP_REPO}"
214
237
  fi
215
238
  )
216
239
 
@@ -65,6 +65,20 @@ function appendAcceptanceHints(baseMatrix, acceptanceHints) {
65
65
  return `${baseMatrix}\n\n## Template Guidance\n${hintLines}\n`;
66
66
  }
67
67
 
68
+ function findGitRoot(startDir) {
69
+ let current = resolve(startDir);
70
+ while (true) {
71
+ if (existsSync(join(current, '.git'))) {
72
+ return current;
73
+ }
74
+ const parent = dirname(current);
75
+ if (parent === current) {
76
+ return null;
77
+ }
78
+ current = parent;
79
+ }
80
+ }
81
+
68
82
  // ── Governed init ───────────────────────────────────────────────────────────
69
83
 
70
84
  const GOVERNED_ROLES = {
@@ -950,6 +964,12 @@ async function initGoverned(opts) {
950
964
  if (dir !== process.cwd()) {
951
965
  console.log(` ${chalk.bold(`cd ${targetLabel}`)}`);
952
966
  }
967
+ if (!findGitRoot(dir)) {
968
+ console.log(` ${chalk.bold('git init')} ${chalk.dim('# initialize the governed repo')}`);
969
+ }
970
+ console.log(` ${chalk.bold('agentxchain template validate')} ${chalk.dim('# prove the scaffold contract before the first turn')}`);
971
+ console.log(` ${chalk.bold('git add -A')} ${chalk.dim('# stage the governed scaffold')}`);
972
+ console.log(` ${chalk.bold('git commit -m "initial governed scaffold"')} ${chalk.dim('# checkpoint the starting state')}`);
953
973
  console.log(` ${chalk.bold('agentxchain step')} ${chalk.dim('# run the first governed turn')}`);
954
974
  console.log(` ${chalk.bold('agentxchain status')} ${chalk.dim('# inspect phase, gate, and turn state')}`);
955
975
  console.log('');
@@ -2,9 +2,15 @@ import chalk from 'chalk';
2
2
  import { loadProjectContext } from '../lib/config.js';
3
3
  import {
4
4
  SCHEDULE_STATE_PATH,
5
+ DAEMON_STATE_PATH,
5
6
  listSchedules,
6
7
  updateScheduleState,
7
8
  evaluateScheduleLaunchEligibility,
9
+ readDaemonState,
10
+ writeDaemonState,
11
+ updateDaemonHeartbeat,
12
+ createDaemonState,
13
+ evaluateDaemonStatus,
8
14
  } from '../lib/run-schedule.js';
9
15
  import { executeGovernedRun } from './run.js';
10
16
 
@@ -221,6 +227,78 @@ export async function scheduleRunDueCommand(opts) {
221
227
  process.exitCode = result.exitCode;
222
228
  }
223
229
 
230
+ export async function scheduleStatusCommand(opts) {
231
+ const context = loadScheduleContext();
232
+ if (!context) return;
233
+
234
+ const raw = readDaemonState(context.root);
235
+ const evaluation = evaluateDaemonStatus(raw);
236
+
237
+ if (opts.json) {
238
+ const output = {
239
+ ok: evaluation.status === 'running' || evaluation.status === 'never_started',
240
+ state_file: DAEMON_STATE_PATH,
241
+ daemon: {
242
+ status: evaluation.status,
243
+ pid: raw?.pid ?? null,
244
+ started_at: raw?.started_at ?? null,
245
+ last_heartbeat_at: raw?.last_heartbeat_at ?? null,
246
+ last_cycle_result: raw?.last_cycle_result ?? null,
247
+ poll_seconds: raw?.poll_seconds ?? null,
248
+ stale_after_seconds: evaluation.stale_after_seconds ?? null,
249
+ last_error: raw?.last_error ?? null,
250
+ },
251
+ };
252
+ if (evaluation.warning) output.daemon.warning = evaluation.warning;
253
+ console.log(JSON.stringify(output, null, 2));
254
+ return;
255
+ }
256
+
257
+ // Human-readable output
258
+ const statusColors = {
259
+ running: chalk.green,
260
+ stale: chalk.yellow,
261
+ not_running: chalk.red,
262
+ never_started: chalk.dim,
263
+ };
264
+ const colorFn = statusColors[evaluation.status] || chalk.white;
265
+
266
+ console.log(chalk.bold('Schedule Daemon Status'));
267
+ console.log(` State: ${colorFn(evaluation.status)}`);
268
+
269
+ if (evaluation.status === 'never_started') {
270
+ console.log(chalk.dim(' No daemon state file found. Run `agentxchain schedule daemon` to start.'));
271
+ return;
272
+ }
273
+
274
+ if (evaluation.warning) {
275
+ console.log(chalk.yellow(` Warning: ${evaluation.warning}`));
276
+ }
277
+
278
+ if (raw?.pid != null) {
279
+ console.log(` PID: ${raw.pid}`);
280
+ }
281
+ if (raw?.started_at) {
282
+ console.log(` Started: ${raw.started_at}`);
283
+ }
284
+ if (raw?.last_heartbeat_at) {
285
+ console.log(` Heartbeat: ${raw.last_heartbeat_at}`);
286
+ }
287
+ if (raw?.last_cycle_result) {
288
+ const resultColor = raw.last_cycle_result === 'ok' ? chalk.green : chalk.red;
289
+ console.log(` Last cycle: ${resultColor(raw.last_cycle_result)}`);
290
+ }
291
+ if (raw?.poll_seconds != null) {
292
+ console.log(` Poll: ${raw.poll_seconds}s`);
293
+ }
294
+ if (evaluation.status === 'stale') {
295
+ console.log(chalk.yellow(` ⚠ Heartbeat is ${evaluation.heartbeat_age_seconds}s old (stale after ${evaluation.stale_after_seconds}s)`));
296
+ }
297
+ if (raw?.last_error) {
298
+ console.log(chalk.red(` Last error: ${raw.last_error}`));
299
+ }
300
+ }
301
+
224
302
  export async function scheduleDaemonCommand(opts) {
225
303
  const context = loadScheduleContext();
226
304
  if (!context) return;
@@ -239,16 +317,31 @@ export async function scheduleDaemonCommand(opts) {
239
317
  }
240
318
 
241
319
  let cycle = 0;
320
+ const daemonState = createDaemonState(process.pid, pollSeconds, opts.schedule || null, maxCycles);
321
+
322
+ try {
323
+ writeDaemonState(context.root, daemonState);
324
+ } catch (err) {
325
+ console.error(chalk.red(`Cannot write daemon state: ${err.message}`));
326
+ process.exitCode = 1;
327
+ return;
328
+ }
329
+
242
330
  if (!opts.json) {
243
331
  console.log(chalk.bold('AgentXchain Schedule Daemon'));
244
332
  console.log(chalk.dim(` Poll: ${pollSeconds}s`));
245
333
  console.log(chalk.dim(` State: ${SCHEDULE_STATE_PATH}`));
334
+ console.log(chalk.dim(` Health: ${DAEMON_STATE_PATH}`));
246
335
  console.log('');
247
336
  }
248
337
 
249
338
  while (true) {
250
339
  cycle += 1;
340
+ daemonState.last_cycle_started_at = new Date().toISOString();
251
341
  const result = await runDueSchedules(context, opts);
342
+
343
+ updateDaemonHeartbeat(context.root, daemonState, result);
344
+
252
345
  if (opts.json) {
253
346
  console.log(JSON.stringify({ cycle, ...result }));
254
347
  }
package/src/lib/export.js CHANGED
@@ -33,6 +33,7 @@ export const RUN_EXPORT_INCLUDED_ROOTS = [
33
33
  '.agentxchain/run-history.jsonl',
34
34
  '.agentxchain/events.jsonl',
35
35
  '.agentxchain/schedule-state.json',
36
+ '.agentxchain/schedule-daemon.json',
36
37
  '.agentxchain/dispatch',
37
38
  '.agentxchain/staging',
38
39
  '.agentxchain/transactions/accept',
@@ -57,6 +58,7 @@ export const RUN_RESTORE_ROOTS = [
57
58
  '.agentxchain/run-history.jsonl',
58
59
  '.agentxchain/events.jsonl',
59
60
  '.agentxchain/schedule-state.json',
61
+ '.agentxchain/schedule-daemon.json',
60
62
  '.agentxchain/dispatch',
61
63
  '.agentxchain/staging',
62
64
  '.agentxchain/transactions/accept',
@@ -44,6 +44,7 @@ const ORCHESTRATOR_STATE_FILES = [
44
44
  '.agentxchain/events.jsonl',
45
45
  '.agentxchain/notification-audit.jsonl',
46
46
  '.agentxchain/schedule-state.json',
47
+ '.agentxchain/schedule-daemon.json',
47
48
  'TALK.md',
48
49
  ];
49
50
 
@@ -4,7 +4,9 @@ import { safeWriteJson } from './safe-write.js';
4
4
  import { loadProjectState } from './config.js';
5
5
 
6
6
  export const SCHEDULE_STATE_PATH = '.agentxchain/schedule-state.json';
7
+ export const DAEMON_STATE_PATH = '.agentxchain/schedule-daemon.json';
7
8
  const SCHEDULE_STATE_SCHEMA_VERSION = '0.1';
9
+ const DAEMON_STATE_SCHEMA_VERSION = '0.1';
8
10
 
9
11
  function parseIsoTime(value) {
10
12
  if (typeof value !== 'string' || !value.trim()) return null;
@@ -158,3 +160,68 @@ export function evaluateScheduleLaunchEligibility(root, config) {
158
160
 
159
161
  return { ok: false, status, reason: `run_${status}` };
160
162
  }
163
+
164
+ // ── Daemon Health State ─────────────────────────────────────────────────────
165
+
166
+ export function readDaemonState(root) {
167
+ const absPath = join(root, DAEMON_STATE_PATH);
168
+ if (!existsSync(absPath)) return null;
169
+ try {
170
+ return JSON.parse(readFileSync(absPath, 'utf8'));
171
+ } catch {
172
+ return { _parse_error: true };
173
+ }
174
+ }
175
+
176
+ export function writeDaemonState(root, state) {
177
+ const absPath = join(root, DAEMON_STATE_PATH);
178
+ mkdirSync(dirname(absPath), { recursive: true });
179
+ safeWriteJson(absPath, { schema_version: DAEMON_STATE_SCHEMA_VERSION, ...state });
180
+ }
181
+
182
+ export function updateDaemonHeartbeat(root, daemonState, cycleResult) {
183
+ const now = new Date().toISOString();
184
+ const updated = {
185
+ ...daemonState,
186
+ last_heartbeat_at: now,
187
+ last_cycle_finished_at: now,
188
+ last_cycle_result: cycleResult.ok ? 'ok' : 'error',
189
+ last_error: cycleResult.ok ? null : (cycleResult.error || 'cycle failed'),
190
+ };
191
+ writeDaemonState(root, updated);
192
+ return updated;
193
+ }
194
+
195
+ export function createDaemonState(pid, pollSeconds, scheduleId, maxCycles) {
196
+ const now = new Date().toISOString();
197
+ return {
198
+ pid,
199
+ started_at: now,
200
+ last_heartbeat_at: now,
201
+ last_cycle_started_at: null,
202
+ last_cycle_finished_at: null,
203
+ last_cycle_result: null,
204
+ poll_seconds: pollSeconds,
205
+ schedule_id: scheduleId || null,
206
+ max_cycles: maxCycles,
207
+ last_error: null,
208
+ };
209
+ }
210
+
211
+ export function evaluateDaemonStatus(daemonState, now = Date.now()) {
212
+ if (!daemonState) return { status: 'never_started' };
213
+ if (daemonState._parse_error) return { status: 'not_running', warning: 'state file is malformed' };
214
+
215
+ const heartbeat = parseIsoTime(daemonState.last_heartbeat_at);
216
+ if (heartbeat === null) return { status: 'not_running', warning: 'no heartbeat recorded' };
217
+
218
+ const pollSeconds = typeof daemonState.poll_seconds === 'number' ? daemonState.poll_seconds : 60;
219
+ const staleAfterSeconds = Math.max(pollSeconds * 3, 30);
220
+ const ageSeconds = (now - heartbeat) / 1000;
221
+
222
+ if (ageSeconds > staleAfterSeconds) {
223
+ return { status: 'stale', stale_after_seconds: staleAfterSeconds, heartbeat_age_seconds: Math.round(ageSeconds) };
224
+ }
225
+
226
+ return { status: 'running', stale_after_seconds: staleAfterSeconds, heartbeat_age_seconds: Math.round(ageSeconds) };
227
+ }