@aiwg/cli 2026.8.2 → 2026.8.4
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/dist/src/a2a/hitl-driver.js +28 -1
- package/dist/src/a2a/hitl.js +32 -8
- package/dist/src/audit/operator-decision.js +180 -0
- package/dist/src/cli/handlers/mc.js +270 -73
- package/dist/src/cli/handlers/subcommands.js +1 -0
- package/dist/src/cli/handlers/use.js +3 -1
- package/dist/src/config/aiwg-config.js +5 -0
- package/dist/src/extensions/claude-hooks-installer.js +9 -5
- package/dist/src/extensions/project-local-doctor.js +10 -26
- package/dist/src/extensions/project-local-remove.js +42 -5
- package/dist/src/mcp/cli.mjs +12 -0
- package/dist/src/mcp/registry.js +14 -1
- package/dist/src/mcp/registry.mjs +15 -1
- package/dist/src/research/query-cli.js +94 -24
- package/dist/src/serve/shared-host-scheduler.js +260 -0
- package/dist/src/storage/backends/fortemi.js +95 -13
- package/dist/src/storage/cli.js +93 -6
- package/package.json +2 -2
- package/tools/plugin/package-plugins.mjs +170 -15
|
@@ -14,10 +14,11 @@
|
|
|
14
14
|
import * as ui from '../ui.js';
|
|
15
15
|
import { promises as fs } from 'node:fs';
|
|
16
16
|
import { join } from 'node:path';
|
|
17
|
-
import { randomBytes } from 'node:crypto';
|
|
17
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
18
18
|
// ── Constants ────────────────────────────────────────────────
|
|
19
19
|
const MC_ROOT = '.aiwg/ralph-external/mc';
|
|
20
20
|
const SESSIONS_DIR = join(MC_ROOT, 'sessions');
|
|
21
|
+
const CONTROL_ID_RE = /^[a-zA-Z0-9._-]+$/;
|
|
21
22
|
// ── Helpers ──────────────────────────────────────────────────
|
|
22
23
|
function genId(prefix) {
|
|
23
24
|
return `${prefix}-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`;
|
|
@@ -26,6 +27,8 @@ async function ensureDir(dir) {
|
|
|
26
27
|
await fs.mkdir(dir, { recursive: true });
|
|
27
28
|
}
|
|
28
29
|
async function readSession(sessionId) {
|
|
30
|
+
if (!CONTROL_ID_RE.test(sessionId))
|
|
31
|
+
return null;
|
|
29
32
|
const path = join(SESSIONS_DIR, sessionId, 'session.json');
|
|
30
33
|
try {
|
|
31
34
|
const raw = await fs.readFile(path, 'utf-8');
|
|
@@ -35,17 +38,88 @@ async function readSession(sessionId) {
|
|
|
35
38
|
return null;
|
|
36
39
|
}
|
|
37
40
|
}
|
|
38
|
-
async function writeSession(session) {
|
|
41
|
+
async function writeSession(session, { touch = true } = {}) {
|
|
39
42
|
const dir = join(SESSIONS_DIR, session.id);
|
|
40
43
|
await ensureDir(dir);
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
if (touch)
|
|
45
|
+
session.updatedAt = new Date().toISOString();
|
|
46
|
+
const destination = join(dir, 'session.json');
|
|
47
|
+
const temporary = join(dir, `.session.${process.pid}.${randomBytes(4).toString('hex')}.tmp`);
|
|
48
|
+
await fs.writeFile(temporary, JSON.stringify(session, null, 2));
|
|
49
|
+
await fs.rename(temporary, destination);
|
|
43
50
|
}
|
|
44
51
|
async function appendLog(sessionId, event) {
|
|
45
52
|
const logPath = join(SESSIONS_DIR, sessionId, 'log.jsonl');
|
|
46
53
|
const entry = JSON.stringify({ ...event, ts: new Date().toISOString() });
|
|
47
54
|
await fs.appendFile(logPath, entry + '\n');
|
|
48
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Apply one durable Mission Control mutation under a cross-process lock.
|
|
58
|
+
* `expectedUpdatedAt` provides optimistic concurrency and `requestId` makes
|
|
59
|
+
* retries idempotent across CLI/Cockpit reconnects.
|
|
60
|
+
*/
|
|
61
|
+
async function mutateSession(sessionId, action, target, expectedUpdatedAt, requestId, mutate) {
|
|
62
|
+
if (!CONTROL_ID_RE.test(sessionId) || !CONTROL_ID_RE.test(target)) {
|
|
63
|
+
return { ok: false, code: 'target_not_found', message: 'invalid Mission control identifier' };
|
|
64
|
+
}
|
|
65
|
+
const dir = join(SESSIONS_DIR, sessionId);
|
|
66
|
+
await ensureDir(dir);
|
|
67
|
+
const lockPath = join(dir, '.control.lock');
|
|
68
|
+
let lock;
|
|
69
|
+
try {
|
|
70
|
+
lock = await fs.open(lockPath, 'wx', 0o600);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (error.code === 'EEXIST') {
|
|
74
|
+
return { ok: false, code: 'mission_conflict', message: 'another Mission control mutation is in progress' };
|
|
75
|
+
}
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const session = await readSession(sessionId);
|
|
80
|
+
if (!session)
|
|
81
|
+
return { ok: false, code: 'session_not_found', message: `Session not found: ${sessionId}` };
|
|
82
|
+
if (expectedUpdatedAt && session.updatedAt !== expectedUpdatedAt) {
|
|
83
|
+
return { ok: false, code: 'mission_conflict', message: `expected revision ${expectedUpdatedAt}; current revision is ${session.updatedAt}` };
|
|
84
|
+
}
|
|
85
|
+
if (requestId && session.mutationKeys?.[requestId]) {
|
|
86
|
+
const prior = session.mutationKeys[requestId];
|
|
87
|
+
if (prior.action !== action || prior.target !== target) {
|
|
88
|
+
return { ok: false, code: 'mission_conflict', message: `request id '${requestId}' was already used for another mutation` };
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, session, replayed: true };
|
|
91
|
+
}
|
|
92
|
+
const errorCode = mutate(session);
|
|
93
|
+
if (errorCode === 'target_not_found')
|
|
94
|
+
return { ok: false, code: errorCode, message: `Mission not found: ${target}` };
|
|
95
|
+
if (errorCode === 'invalid_state')
|
|
96
|
+
return { ok: false, code: errorCode, message: `Mutation '${action}' is invalid from state '${session.state}'` };
|
|
97
|
+
if (errorCode === 'capacity')
|
|
98
|
+
return { ok: false, code: errorCode, message: `Session at capacity (${session.maxMissions} missions)` };
|
|
99
|
+
const nextUpdatedAt = new Date().toISOString();
|
|
100
|
+
session.updatedAt = nextUpdatedAt;
|
|
101
|
+
if (requestId) {
|
|
102
|
+
session.mutationKeys = { ...(session.mutationKeys ?? {}), [requestId]: { action, target, updatedAt: nextUpdatedAt } };
|
|
103
|
+
}
|
|
104
|
+
await writeSession(session, { touch: false });
|
|
105
|
+
await appendLog(session.id, { event: `control_${action}`, target, requestId: requestId ?? null, replayed: false, revision: session.updatedAt });
|
|
106
|
+
return { ok: true, session, replayed: false };
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
await lock.close();
|
|
110
|
+
await fs.unlink(lockPath).catch(() => undefined);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function mutationFlags(args) {
|
|
114
|
+
return {
|
|
115
|
+
expectedUpdatedAt: parseFlag(args, '--expected-updated-at'),
|
|
116
|
+
requestId: parseFlag(args, '--request-id'),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function mutationFailure(result) {
|
|
120
|
+
ui.error(`${result.code}: ${result.message}`);
|
|
121
|
+
return { exitCode: result.code === 'mission_conflict' ? 3 : 1, message: `${result.code}: ${result.message}` };
|
|
122
|
+
}
|
|
49
123
|
async function listSessions() {
|
|
50
124
|
try {
|
|
51
125
|
const entries = await fs.readdir(SESSIONS_DIR, { withFileTypes: true });
|
|
@@ -142,7 +216,9 @@ Queue a mission onto a session. Does NOT execute — use 'aiwg mc run' to launch
|
|
|
142
216
|
(off unless declared; no default K)
|
|
143
217
|
--budget-stop-policy P completion-wins (default) | budget-wins
|
|
144
218
|
--mode pty-orchestrator PTY-orchestrator mode (requires --target-agent)
|
|
145
|
-
--target-agent <id> Required for --mode pty-orchestrator
|
|
219
|
+
--target-agent <id> Required for --mode pty-orchestrator
|
|
220
|
+
--expected-updated-at <time> Reject stale state with exit 3
|
|
221
|
+
--request-id <id> Idempotent dispatch key`,
|
|
146
222
|
run: `Usage: aiwg mc run [<session-id>] [--accept-cost]
|
|
147
223
|
|
|
148
224
|
Drain queued missions in a session by launching each as a ralph loop. Missions
|
|
@@ -160,13 +236,22 @@ Show mission status for a session. Auto-syncs from ralph loop state files.
|
|
|
160
236
|
Live-monitor mission progress (non-interactive context prints status once).`,
|
|
161
237
|
abort: `Usage: aiwg mc abort <session-id> <mission-id>
|
|
162
238
|
|
|
163
|
-
Mark a specific mission as aborted
|
|
239
|
+
Mark a specific mission as aborted.
|
|
240
|
+
|
|
241
|
+
--expected-updated-at <timestamp> Reject stale state with exit 3
|
|
242
|
+
--request-id <id> Idempotent mutation key`,
|
|
164
243
|
pause: `Usage: aiwg mc pause [<session-id>]
|
|
165
244
|
|
|
166
|
-
Pause an active session; running missions transition to 'paused' status
|
|
245
|
+
Pause an active session; running missions transition to 'paused' status.
|
|
246
|
+
|
|
247
|
+
--expected-updated-at <timestamp> Reject stale state with exit 3
|
|
248
|
+
--request-id <id> Idempotent mutation key`,
|
|
167
249
|
resume: `Usage: aiwg mc resume [<session-id>]
|
|
168
250
|
|
|
169
|
-
Resume a paused session; paused missions transition back to 'running'
|
|
251
|
+
Resume a paused session; paused missions transition back to 'running'.
|
|
252
|
+
|
|
253
|
+
--expected-updated-at <timestamp> Reject stale state with exit 3
|
|
254
|
+
--request-id <id> Idempotent mutation key`,
|
|
170
255
|
stop: `Usage: aiwg mc stop [<session-id>] [--drain]
|
|
171
256
|
|
|
172
257
|
Shut down a session.
|
|
@@ -289,15 +374,11 @@ async function mcDispatch(ctx) {
|
|
|
289
374
|
ui.error('--mode pty-orchestrator requires --target-agent <agent-id>');
|
|
290
375
|
return { exitCode: 1 };
|
|
291
376
|
}
|
|
292
|
-
const
|
|
293
|
-
if (!
|
|
377
|
+
const selected = await findActiveSession(sessionId);
|
|
378
|
+
if (!selected) {
|
|
294
379
|
ui.error(sessionId ? `Session not found: ${sessionId}` : 'No active session. Run `aiwg mc start` first.');
|
|
295
380
|
return { exitCode: 1 };
|
|
296
381
|
}
|
|
297
|
-
if (session.missions.length >= session.maxMissions) {
|
|
298
|
-
ui.error(`Session at capacity (${session.maxMissions} missions). Increase with --max-missions or stop completed missions.`);
|
|
299
|
-
return { exitCode: 1 };
|
|
300
|
-
}
|
|
301
382
|
// #1361: Check project-level parallelism cap. Active missions = running or
|
|
302
383
|
// queued; if at cap, warn but still queue (FIFO behavior — the mission goes
|
|
303
384
|
// into the session as 'queued' and will run when a slot frees up).
|
|
@@ -307,7 +388,7 @@ async function mcDispatch(ctx) {
|
|
|
307
388
|
const cfg = await readAiwgConfig(ctx.cwd || process.cwd());
|
|
308
389
|
if (cfg) {
|
|
309
390
|
const resolved = resolveParallelism(cfg.parallelism, cfg.providers[0]);
|
|
310
|
-
const activeCount =
|
|
391
|
+
const activeCount = selected.missions.filter(m => m.status === 'running' || m.status === 'queued' || m.status === 'paused').length;
|
|
311
392
|
if (activeCount >= resolved.max_parallel_mc_missions) {
|
|
312
393
|
capWarning = `Active missions (${activeCount}) at or above project parallelism cap (${resolved.max_parallel_mc_missions}). Mission will queue; bump via 'aiwg config set --project parallelism.max_parallel_mc_missions N'.`;
|
|
313
394
|
}
|
|
@@ -316,8 +397,11 @@ async function mcDispatch(ctx) {
|
|
|
316
397
|
catch {
|
|
317
398
|
// Non-fatal — config read failure doesn't block dispatch
|
|
318
399
|
}
|
|
400
|
+
const flags = mutationFlags(ctx.args);
|
|
319
401
|
const mission = {
|
|
320
|
-
id:
|
|
402
|
+
id: flags.requestId
|
|
403
|
+
? `m-${createHash('sha256').update(flags.requestId).digest('hex').slice(0, 16)}`
|
|
404
|
+
: genId('m'),
|
|
321
405
|
objective,
|
|
322
406
|
completion,
|
|
323
407
|
status: 'queued',
|
|
@@ -334,27 +418,38 @@ async function mcDispatch(ctx) {
|
|
|
334
418
|
mode,
|
|
335
419
|
targetAgent: targetAgent || undefined,
|
|
336
420
|
};
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
mode,
|
|
345
|
-
targetAgent,
|
|
346
|
-
lfdBudgets: {
|
|
347
|
-
maxTotalTokens,
|
|
348
|
-
maxOutputTokens,
|
|
349
|
-
maxToolCalls,
|
|
350
|
-
maxTotalCost,
|
|
351
|
-
maxWallClockMinutes,
|
|
352
|
-
explorationQuota,
|
|
353
|
-
},
|
|
421
|
+
const result = await mutateSession(selected.id, 'dispatch', mission.id, flags.expectedUpdatedAt, flags.requestId, session => {
|
|
422
|
+
if (session.state !== 'active')
|
|
423
|
+
return 'invalid_state';
|
|
424
|
+
if (session.missions.length >= session.maxMissions)
|
|
425
|
+
return 'capacity';
|
|
426
|
+
session.missions.push(mission);
|
|
427
|
+
return undefined;
|
|
354
428
|
});
|
|
429
|
+
if (!result.ok)
|
|
430
|
+
return mutationFailure(result);
|
|
431
|
+
if (!result.replayed) {
|
|
432
|
+
await appendLog(selected.id, {
|
|
433
|
+
event: 'mission_dispatched',
|
|
434
|
+
missionId: mission.id,
|
|
435
|
+
objective,
|
|
436
|
+
priority,
|
|
437
|
+
mode,
|
|
438
|
+
targetAgent,
|
|
439
|
+
requestId: flags.requestId ?? null,
|
|
440
|
+
lfdBudgets: {
|
|
441
|
+
maxTotalTokens,
|
|
442
|
+
maxOutputTokens,
|
|
443
|
+
maxToolCalls,
|
|
444
|
+
maxTotalCost,
|
|
445
|
+
maxWallClockMinutes,
|
|
446
|
+
explorationQuota,
|
|
447
|
+
},
|
|
448
|
+
});
|
|
449
|
+
}
|
|
355
450
|
if (capWarning)
|
|
356
451
|
ui.warn(capWarning);
|
|
357
|
-
ui.success(
|
|
452
|
+
ui.success(`${result.replayed ? 'Replayed dispatch for' : 'Dispatched mission'} ${mission.id}: ${objective}`);
|
|
358
453
|
const modeLabel = mode === 'pty-orchestrator' ? ` | Mode: PTY orchestrator → ${targetAgent}` : '';
|
|
359
454
|
ui.info(`Priority: ${priority} | Max iterations: ${maxIterations}${modeLabel}`);
|
|
360
455
|
const lfdLimits = [
|
|
@@ -370,7 +465,7 @@ async function mcDispatch(ctx) {
|
|
|
370
465
|
}
|
|
371
466
|
// #1439: dispatch alone does NOT execute the mission. Surface the next step
|
|
372
467
|
// so the user knows the queue won't drain on its own.
|
|
373
|
-
ui.info(`Next: run \`aiwg mc run ${
|
|
468
|
+
ui.info(`Next: run \`aiwg mc run ${selected.id}\` to launch queued missions as ralph loops.`);
|
|
374
469
|
return { exitCode: 0, message: mission.id };
|
|
375
470
|
}
|
|
376
471
|
/**
|
|
@@ -445,6 +540,18 @@ async function mcRun(ctx) {
|
|
|
445
540
|
let failed = 0;
|
|
446
541
|
const projectRoot = ctx.cwd || process.cwd();
|
|
447
542
|
const frameworkRoot = ctx.frameworkRoot;
|
|
543
|
+
const { readAiwgConfig, resolveParallelism } = await import('../../config/aiwg-config.js');
|
|
544
|
+
const { FileAdmissionStore, SharedHostScheduler } = await import('../../serve/shared-host-scheduler.js');
|
|
545
|
+
const cfg = await readAiwgConfig(projectRoot).catch(() => null);
|
|
546
|
+
const provider = cfg?.providers[0] ?? 'unknown';
|
|
547
|
+
const maxConcurrent = resolveParallelism(cfg?.parallelism, provider).max_parallel_mc_missions;
|
|
548
|
+
const scheduler = new SharedHostScheduler(new FileAdmissionStore(join(projectRoot, MC_ROOT, 'admission.json')), {
|
|
549
|
+
maxConcurrent,
|
|
550
|
+
leaseTtlMs: 5 * 60_000,
|
|
551
|
+
agingIntervalMs: 30_000,
|
|
552
|
+
allowPreemption: false,
|
|
553
|
+
defaultHostQuota: maxConcurrent,
|
|
554
|
+
});
|
|
448
555
|
for (const mission of queued) {
|
|
449
556
|
if (mission.mode === 'pty-orchestrator') {
|
|
450
557
|
ui.warn(`Mission ${mission.id} mode=pty-orchestrator is not yet wired to mc run; skipping. Use 'aiwg ralph' directly for PTY-orchestrator workflows.`);
|
|
@@ -456,6 +563,40 @@ async function mcRun(ctx) {
|
|
|
456
563
|
skipped += 1;
|
|
457
564
|
continue;
|
|
458
565
|
}
|
|
566
|
+
const admissionRequestId = mission.admissionState === 'released'
|
|
567
|
+
? `${session.id}.${mission.id}.${Date.now().toString(36)}`
|
|
568
|
+
: mission.admissionRequestId ?? `${session.id}.${mission.id}`;
|
|
569
|
+
const admissionSubmittedAt = mission.admissionState === 'released'
|
|
570
|
+
? new Date().toISOString()
|
|
571
|
+
: mission.admissionSubmittedAt ?? new Date().toISOString();
|
|
572
|
+
const admission = scheduler.submit({
|
|
573
|
+
requestId: admissionRequestId,
|
|
574
|
+
orchestratorId: session.id,
|
|
575
|
+
environment: process.env.AIWG_ENVIRONMENT ?? 'default',
|
|
576
|
+
provider,
|
|
577
|
+
runtimeKind: 'host',
|
|
578
|
+
priority: mission.priority === 'critical' ? 100 : mission.priority === 'high' ? 50 : mission.priority === 'low' ? 0 : 10,
|
|
579
|
+
submittedAt: admissionSubmittedAt,
|
|
580
|
+
queueTimeoutMs: 24 * 60 * 60_000,
|
|
581
|
+
preemptible: false,
|
|
582
|
+
metadata: { missionId: mission.id },
|
|
583
|
+
});
|
|
584
|
+
mission.admissionRequestId = admissionRequestId;
|
|
585
|
+
mission.admissionSubmittedAt = admissionSubmittedAt;
|
|
586
|
+
mission.admissionState = admission.state;
|
|
587
|
+
mission.admissionLeaseExpiresAt = admission.leaseExpiresAt;
|
|
588
|
+
await writeSession(session);
|
|
589
|
+
if (admission.state !== 'admitted') {
|
|
590
|
+
await appendLog(session.id, {
|
|
591
|
+
event: 'mission_admission_queued',
|
|
592
|
+
missionId: mission.id,
|
|
593
|
+
admissionRequestId,
|
|
594
|
+
reason: admission.reason,
|
|
595
|
+
});
|
|
596
|
+
ui.info(`Queued ${mission.id}: ${admission.reason}`);
|
|
597
|
+
skipped += 1;
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
459
600
|
try {
|
|
460
601
|
const result = await launchExternalRalph(frameworkRoot, projectRoot, {
|
|
461
602
|
objective: mission.objective,
|
|
@@ -490,6 +631,9 @@ async function mcRun(ctx) {
|
|
|
490
631
|
mission.status = 'failed';
|
|
491
632
|
mission.error = msg;
|
|
492
633
|
mission.completedAt = new Date().toISOString();
|
|
634
|
+
scheduler.release(admissionRequestId);
|
|
635
|
+
mission.admissionState = 'released';
|
|
636
|
+
mission.admissionLeaseExpiresAt = undefined;
|
|
493
637
|
await writeSession(session);
|
|
494
638
|
await appendLog(session.id, {
|
|
495
639
|
event: 'mission_launch_failed',
|
|
@@ -522,6 +666,12 @@ async function mcRun(ctx) {
|
|
|
522
666
|
*/
|
|
523
667
|
async function syncMissionsFromRalph(session, projectRoot) {
|
|
524
668
|
let mutated = false;
|
|
669
|
+
const { readAiwgConfig, resolveParallelism } = await import('../../config/aiwg-config.js');
|
|
670
|
+
const { FileAdmissionStore, SharedHostScheduler } = await import('../../serve/shared-host-scheduler.js');
|
|
671
|
+
const cfg = await readAiwgConfig(projectRoot).catch(() => null);
|
|
672
|
+
const provider = cfg?.providers[0] ?? 'unknown';
|
|
673
|
+
const maxConcurrent = resolveParallelism(cfg?.parallelism, provider).max_parallel_mc_missions;
|
|
674
|
+
const scheduler = new SharedHostScheduler(new FileAdmissionStore(join(projectRoot, MC_ROOT, 'admission.json')), { maxConcurrent, leaseTtlMs: 5 * 60_000, agingIntervalMs: 30_000, allowPreemption: false, defaultHostQuota: maxConcurrent });
|
|
525
675
|
for (const mission of session.missions) {
|
|
526
676
|
if (mission.status !== 'running')
|
|
527
677
|
continue;
|
|
@@ -560,6 +710,25 @@ async function syncMissionsFromRalph(session, projectRoot) {
|
|
|
560
710
|
mission.error = state.error;
|
|
561
711
|
}
|
|
562
712
|
mutated = true;
|
|
713
|
+
if (mission.admissionRequestId) {
|
|
714
|
+
try {
|
|
715
|
+
scheduler.release(mission.admissionRequestId);
|
|
716
|
+
}
|
|
717
|
+
catch { /* already expired or released */ }
|
|
718
|
+
mission.admissionState = 'released';
|
|
719
|
+
mission.admissionLeaseExpiresAt = undefined;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
else if (mission.admissionRequestId) {
|
|
723
|
+
try {
|
|
724
|
+
const renewed = scheduler.renew(mission.admissionRequestId);
|
|
725
|
+
mission.admissionState = renewed.state;
|
|
726
|
+
mission.admissionLeaseExpiresAt = renewed.leaseExpiresAt;
|
|
727
|
+
mutated = true;
|
|
728
|
+
}
|
|
729
|
+
catch {
|
|
730
|
+
// An expired lease is reconciled on the next run; status remains best-effort.
|
|
731
|
+
}
|
|
563
732
|
}
|
|
564
733
|
}
|
|
565
734
|
catch {
|
|
@@ -662,58 +831,85 @@ async function mcAbort(ctx) {
|
|
|
662
831
|
ui.error('Usage: aiwg mc abort <session-id> <mission-id>');
|
|
663
832
|
return { exitCode: 1 };
|
|
664
833
|
}
|
|
665
|
-
const
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
834
|
+
const flags = mutationFlags(ctx.args);
|
|
835
|
+
const result = await mutateSession(sessionId, 'cancel', missionId, flags.expectedUpdatedAt, flags.requestId, session => {
|
|
836
|
+
const mission = session.missions.find(m => m.id === missionId);
|
|
837
|
+
if (!mission)
|
|
838
|
+
return 'target_not_found';
|
|
839
|
+
if (mission.status === 'done' || mission.status === 'failed' || mission.status === 'aborted')
|
|
840
|
+
return 'invalid_state';
|
|
841
|
+
mission.status = 'aborted';
|
|
842
|
+
mission.completedAt = new Date().toISOString();
|
|
843
|
+
return undefined;
|
|
844
|
+
});
|
|
845
|
+
if (!result.ok)
|
|
846
|
+
return mutationFailure(result);
|
|
847
|
+
if (!result.replayed) {
|
|
848
|
+
const mission = result.session.missions.find(candidate => candidate.id === missionId);
|
|
849
|
+
if (mission?.admissionRequestId) {
|
|
850
|
+
try {
|
|
851
|
+
const projectRoot = ctx.cwd || process.cwd();
|
|
852
|
+
const { readAiwgConfig, resolveParallelism } = await import('../../config/aiwg-config.js');
|
|
853
|
+
const { FileAdmissionStore, SharedHostScheduler } = await import('../../serve/shared-host-scheduler.js');
|
|
854
|
+
const cfg = await readAiwgConfig(projectRoot).catch(() => null);
|
|
855
|
+
const provider = cfg?.providers[0] ?? 'unknown';
|
|
856
|
+
const maxConcurrent = resolveParallelism(cfg?.parallelism, provider).max_parallel_mc_missions;
|
|
857
|
+
const scheduler = new SharedHostScheduler(new FileAdmissionStore(join(projectRoot, MC_ROOT, 'admission.json')), { maxConcurrent, leaseTtlMs: 5 * 60_000, agingIntervalMs: 30_000, allowPreemption: false, defaultHostQuota: maxConcurrent });
|
|
858
|
+
scheduler.cancel(mission.admissionRequestId);
|
|
859
|
+
}
|
|
860
|
+
catch {
|
|
861
|
+
// The mission state is authoritative; lease expiry provides recovery.
|
|
862
|
+
}
|
|
863
|
+
}
|
|
674
864
|
}
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
await writeSession(session);
|
|
678
|
-
await appendLog(session.id, { event: 'mission_aborted', missionId });
|
|
679
|
-
ui.success(`Aborted mission: ${missionId}`);
|
|
680
|
-
return { exitCode: 0 };
|
|
865
|
+
ui.success(`${result.replayed ? 'Replayed' : 'Aborted'} mission: ${missionId}`);
|
|
866
|
+
return { exitCode: 0, message: JSON.stringify({ ok: true, replayed: result.replayed, updated_at: result.session.updatedAt }) };
|
|
681
867
|
}
|
|
682
868
|
async function mcPause(ctx) {
|
|
683
869
|
const positional = getPositionalArgs(ctx.args);
|
|
684
870
|
const sessionId = positional[0];
|
|
685
|
-
const
|
|
686
|
-
if (!
|
|
871
|
+
const selected = await findActiveSession(sessionId);
|
|
872
|
+
if (!selected) {
|
|
687
873
|
ui.error('No active session to pause.');
|
|
688
874
|
return { exitCode: 1 };
|
|
689
875
|
}
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
if (
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
876
|
+
const flags = mutationFlags(ctx.args);
|
|
877
|
+
const result = await mutateSession(selected.id, 'pause', selected.id, flags.expectedUpdatedAt, flags.requestId, session => {
|
|
878
|
+
if (session.state !== 'active')
|
|
879
|
+
return 'invalid_state';
|
|
880
|
+
session.state = 'paused';
|
|
881
|
+
for (const mission of session.missions)
|
|
882
|
+
if (mission.status === 'running')
|
|
883
|
+
mission.status = 'paused';
|
|
884
|
+
return undefined;
|
|
885
|
+
});
|
|
886
|
+
if (!result.ok)
|
|
887
|
+
return mutationFailure(result);
|
|
888
|
+
ui.success(`${result.replayed ? 'Replayed pause for' : 'Paused'} session: ${selected.id}`);
|
|
889
|
+
return { exitCode: 0, message: JSON.stringify({ ok: true, replayed: result.replayed, updated_at: result.session.updatedAt }) };
|
|
699
890
|
}
|
|
700
891
|
async function mcResume(ctx) {
|
|
701
892
|
const positional = getPositionalArgs(ctx.args);
|
|
702
893
|
const sessionId = positional[0];
|
|
703
|
-
const
|
|
704
|
-
if (!
|
|
894
|
+
const selected = await findActiveSession(sessionId);
|
|
895
|
+
if (!selected || selected.state !== 'paused') {
|
|
705
896
|
ui.error('No paused session to resume.');
|
|
706
897
|
return { exitCode: 1 };
|
|
707
898
|
}
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
if (
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
899
|
+
const flags = mutationFlags(ctx.args);
|
|
900
|
+
const result = await mutateSession(selected.id, 'resume', selected.id, flags.expectedUpdatedAt, flags.requestId, session => {
|
|
901
|
+
if (session.state !== 'paused')
|
|
902
|
+
return 'invalid_state';
|
|
903
|
+
session.state = 'active';
|
|
904
|
+
for (const mission of session.missions)
|
|
905
|
+
if (mission.status === 'paused')
|
|
906
|
+
mission.status = 'running';
|
|
907
|
+
return undefined;
|
|
908
|
+
});
|
|
909
|
+
if (!result.ok)
|
|
910
|
+
return mutationFailure(result);
|
|
911
|
+
ui.success(`${result.replayed ? 'Replayed resume for' : 'Resumed'} session: ${selected.id}`);
|
|
912
|
+
return { exitCode: 0, message: JSON.stringify({ ok: true, replayed: result.replayed, updated_at: result.session.updatedAt }) };
|
|
717
913
|
}
|
|
718
914
|
async function mcStop(ctx) {
|
|
719
915
|
const positional = getPositionalArgs(ctx.args);
|
|
@@ -912,6 +1108,7 @@ const subcommands = {
|
|
|
912
1108
|
status: mcStatus,
|
|
913
1109
|
watch: mcWatch,
|
|
914
1110
|
abort: mcAbort,
|
|
1111
|
+
cancel: mcAbort,
|
|
915
1112
|
pause: mcPause,
|
|
916
1113
|
resume: mcResume,
|
|
917
1114
|
stop: mcStop,
|
|
@@ -945,7 +1142,7 @@ function showMcHelp() {
|
|
|
945
1142
|
Cost gate warns/refuses above ~$5 estimate
|
|
946
1143
|
status [<id>] [--json] View mission status dashboard
|
|
947
1144
|
watch [<id>] Live monitor (streaming)
|
|
948
|
-
abort <session> <mission>
|
|
1145
|
+
abort|cancel <session> <mission> Abort a specific mission
|
|
949
1146
|
pause [<id>] Pause active session
|
|
950
1147
|
resume [<id>] Resume paused session
|
|
951
1148
|
stop [<id>] [--drain] Shut down session
|
|
@@ -1062,6 +1062,7 @@ export const installPluginHandler = {
|
|
|
1062
1062
|
const runner = createScriptRunner(frameworkRoot);
|
|
1063
1063
|
return runner.run("tools/plugin/plugin-installer-cli.mjs", ctx.args, {
|
|
1064
1064
|
cwd: ctx.cwd,
|
|
1065
|
+
env: { AIWG_ROOT: frameworkRoot },
|
|
1065
1066
|
});
|
|
1066
1067
|
},
|
|
1067
1068
|
};
|
|
@@ -31,7 +31,7 @@ import { PROJECT_LOCAL_TYPE_TO_DIR } from '../../extensions/project-local-paths.
|
|
|
31
31
|
import { buildUpstreamRegistry } from '../../extensions/upstream-registry.js';
|
|
32
32
|
import { resolveShadows, formatShadowReport, } from '../../extensions/shadow-resolver.js';
|
|
33
33
|
import { appendProjectLocalActivity, emitDiscoverEventsDeduped, } from '../../extensions/project-local-activity.js';
|
|
34
|
-
import { hashBundleArtifacts } from '../../extensions/project-local-remove.js';
|
|
34
|
+
import { hashBundleArtifacts, hashDeployedBundleArtifacts, } from '../../extensions/project-local-remove.js';
|
|
35
35
|
import { installAiwgHooks } from '../../extensions/claude-hooks-installer.js';
|
|
36
36
|
import { detectScope, mirrorToUserScope, rejectOpenClawProjectScope, USER_SCOPE_PATHS, } from '../scope-resolver.js';
|
|
37
37
|
import { maybeWarnProjectIsolation } from '../project-isolation/index.js';
|
|
@@ -1185,6 +1185,7 @@ async function deployProjectLocalBundles(opts) {
|
|
|
1185
1185
|
// #1037 — record per-artifact source hashes so `aiwg remove` can
|
|
1186
1186
|
// detect pristine vs mutated vs replaced deployed files.
|
|
1187
1187
|
const artifactHashes = await hashBundleArtifacts(bundle.artifactPath);
|
|
1188
|
+
const deployedArtifactHashes = await hashDeployedBundleArtifacts(projectDir, provider, artifactHashes);
|
|
1188
1189
|
const updated = updateInstalled(config, bundle.id, provider, result.counts, {
|
|
1189
1190
|
version: bundle.manifest.version,
|
|
1190
1191
|
source: 'project-local',
|
|
@@ -1193,6 +1194,7 @@ async function deployProjectLocalBundles(opts) {
|
|
|
1193
1194
|
localType: bundle.type,
|
|
1194
1195
|
manifestVersion: bundle.manifest.manifestVersion,
|
|
1195
1196
|
artifactHashes,
|
|
1197
|
+
deployedArtifactHashes,
|
|
1196
1198
|
});
|
|
1197
1199
|
await writeAiwgConfig(projectDir, updated);
|
|
1198
1200
|
}
|
|
@@ -784,6 +784,10 @@ export function updateInstalled(config, name, provider, counts, opts) {
|
|
|
784
784
|
existing.manifestVersion = opts.manifestVersion;
|
|
785
785
|
if (opts.artifactHashes)
|
|
786
786
|
existing.artifactHashes = opts.artifactHashes;
|
|
787
|
+
if (opts.deployedArtifactHashes) {
|
|
788
|
+
existing.deployedArtifactHashes ??= {};
|
|
789
|
+
existing.deployedArtifactHashes[provider] = opts.deployedArtifactHashes;
|
|
790
|
+
}
|
|
787
791
|
}
|
|
788
792
|
else {
|
|
789
793
|
// Clear stale project-local fields if a previously project-local entry is
|
|
@@ -792,6 +796,7 @@ export function updateInstalled(config, name, provider, counts, opts) {
|
|
|
792
796
|
delete existing.localType;
|
|
793
797
|
delete existing.manifestVersion;
|
|
794
798
|
delete existing.artifactHashes;
|
|
799
|
+
delete existing.deployedArtifactHashes;
|
|
795
800
|
}
|
|
796
801
|
config.installed[name] = existing;
|
|
797
802
|
return config;
|
|
@@ -34,9 +34,12 @@ import * as path from 'node:path';
|
|
|
34
34
|
* Per the script-source comments at agentic/code/addons/aiwg-hooks/hooks/.
|
|
35
35
|
*/
|
|
36
36
|
const HOOK_SCRIPTS = [
|
|
37
|
-
{ file: 'aiwg-permissions.cjs', events:
|
|
38
|
-
{ file: 'aiwg-session.cjs', events:
|
|
39
|
-
{
|
|
37
|
+
{ file: 'aiwg-permissions.cjs', events: { PermissionRequest: undefined } },
|
|
38
|
+
{ file: 'aiwg-session.cjs', events: { SessionStart: undefined } },
|
|
39
|
+
{
|
|
40
|
+
file: 'aiwg-trace.cjs',
|
|
41
|
+
events: { SubagentStart: 'start', SubagentStop: 'stop' },
|
|
42
|
+
},
|
|
40
43
|
];
|
|
41
44
|
/**
|
|
42
45
|
* Migrate a legacy array-shaped `hooks` field to the object form Claude
|
|
@@ -185,9 +188,10 @@ export async function installAiwgHooks(opts) {
|
|
|
185
188
|
// embedded script path POSIX-style on every host: native Windows
|
|
186
189
|
// backslashes are interpreted as escapes and collapse into a nonexistent
|
|
187
190
|
// path such as `.claudehooksaiwg-session.cjs` (#133).
|
|
188
|
-
const
|
|
191
|
+
const script = path.posix.join('.claude', 'hooks', file);
|
|
189
192
|
const hookId = file.replace(/\.(cjs|js)$/, '');
|
|
190
|
-
for (const event of events) {
|
|
193
|
+
for (const [event, argument] of Object.entries(events)) {
|
|
194
|
+
const command = `node ${script}${argument ? ` ${argument}` : ''}`;
|
|
191
195
|
if (!hooksObj[event])
|
|
192
196
|
hooksObj[event] = [];
|
|
193
197
|
const groups = hooksObj[event];
|
|
@@ -12,8 +12,7 @@
|
|
|
12
12
|
* @design @.aiwg/architecture/design-doctor-log-promote.md
|
|
13
13
|
* @implements #1037
|
|
14
14
|
*/
|
|
15
|
-
import { join
|
|
16
|
-
import { homedir } from 'os';
|
|
15
|
+
import { join } from 'path';
|
|
17
16
|
import { discoverProjectLocalBundles } from './project-local-discovery.js';
|
|
18
17
|
import { buildUpstreamRegistry } from './upstream-registry.js';
|
|
19
18
|
import { resolveShadows } from './shadow-resolver.js';
|
|
@@ -22,6 +21,7 @@ import { sha256OfFileRawAndNormalized } from './managed-marker.js';
|
|
|
22
21
|
import { projectAiwgPath } from '../config/project-artifacts.js';
|
|
23
22
|
import { projectRelativePathIfInside } from './project-local-paths.js';
|
|
24
23
|
import { auditProjectQuickref } from './project-quickref.js';
|
|
24
|
+
import { artifactHashesForProvider, candidateDeployedPaths } from './project-local-remove.js';
|
|
25
25
|
/**
|
|
26
26
|
* Hash a deployed file in raw and managed-marker-normalized forms. Returns
|
|
27
27
|
* null on read errors (e.g., file missing — caller treats as
|
|
@@ -48,23 +48,6 @@ const TYPE_DIR = {
|
|
|
48
48
|
plugin: 'plugins',
|
|
49
49
|
provider: 'providers',
|
|
50
50
|
};
|
|
51
|
-
// Per PUW-026 (#1127): home-deploying providers get absolute prefixes so
|
|
52
|
-
// `resolve(projectDir, prefix)` correctly produces the home-rooted path
|
|
53
|
-
// (resolve treats absolute paths as authoritative). Previously these were
|
|
54
|
-
// `null`, which silently skipped lifecycle operations against home-deployed
|
|
55
|
-
// project-local bundles.
|
|
56
|
-
const PROVIDER_PREFIX = {
|
|
57
|
-
claude: '.claude',
|
|
58
|
-
cursor: '.cursor',
|
|
59
|
-
factory: '.factory',
|
|
60
|
-
opencode: '.opencode',
|
|
61
|
-
windsurf: '.windsurf',
|
|
62
|
-
warp: '.warp',
|
|
63
|
-
codex: '.codex',
|
|
64
|
-
copilot: '.github',
|
|
65
|
-
openclaw: resolve(homedir(), '.openclaw'),
|
|
66
|
-
hermes: resolve(homedir(), '.hermes'),
|
|
67
|
-
};
|
|
68
51
|
export async function buildProjectLocalDoctorSection(opts) {
|
|
69
52
|
const { projectDir, frameworkRoot, config, quiet = false } = opts;
|
|
70
53
|
const discovery = await discoverProjectLocalBundles(projectDir);
|
|
@@ -165,18 +148,19 @@ export async function buildProjectLocalDoctorSection(opts) {
|
|
|
165
148
|
const entry = config.installed[bundle.id];
|
|
166
149
|
if (!entry || entry.source !== 'project-local')
|
|
167
150
|
continue;
|
|
168
|
-
|
|
169
|
-
if (!hashes) {
|
|
151
|
+
if (!entry.artifactHashes && !entry.deployedArtifactHashes) {
|
|
170
152
|
unhashedSeen = true;
|
|
171
153
|
continue;
|
|
172
154
|
}
|
|
173
155
|
for (const provider of Object.keys(entry.deployedTo)) {
|
|
174
|
-
const
|
|
175
|
-
if (!prefix)
|
|
176
|
-
continue;
|
|
156
|
+
const hashes = artifactHashesForProvider(entry, provider);
|
|
177
157
|
for (const [sourceRel, expectedHash] of Object.entries(hashes)) {
|
|
178
|
-
|
|
179
|
-
const
|
|
158
|
+
let actualHash = null;
|
|
159
|
+
for (const deployedAbs of candidateDeployedPaths(projectDir, provider, sourceRel)) {
|
|
160
|
+
actualHash = await hashDeployed(deployedAbs);
|
|
161
|
+
if (actualHash)
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
180
164
|
if (actualHash === null) {
|
|
181
165
|
// Missing — not drift, deploy is just absent
|
|
182
166
|
continue;
|