agentic-workflow-manager 9.22.0 → 9.22.1
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/commands/change/index.js +2 -2
- package/dist/src/commands/init.js +8 -4
- package/dist/src/commands/registry/add.js +9 -3
- package/dist/src/commands/sync.js +11 -5
- package/dist/src/commands/update.js +23 -12
- package/dist/src/core/changes/derive.js +6 -3
- package/dist/src/core/changes/git.js +1 -2
- package/dist/src/core/changes/worktree.js +15 -3
- package/dist/src/core/init/steps.js +3 -8
- package/dist/src/core/registries.js +20 -15
- package/dist/src/core/registry-git-errors.js +107 -0
- package/dist/src/core/registry-git.js +93 -0
- package/dist/src/core/registry-sync-report.js +74 -0
- package/dist/src/core/versioning.js +3 -6
- package/dist/src/index.js +5 -8
- package/dist/tests/commands/change/new.test.js +103 -57
- package/dist/tests/commands/change/validate.test.js +164 -108
- package/dist/tests/commands/changes/list.test.js +41 -22
- package/dist/tests/commands/job/gate-reconcile.test.js +13 -2
- package/dist/tests/commands/registry-sync-honesty.test.js +175 -0
- package/dist/tests/commands/sensors/exec.test.js +3 -1
- package/dist/tests/commands/update.test.js +8 -8
- package/dist/tests/commands/watch/supervisor-loop.test.js +21 -5
- package/dist/tests/core/changes/helpers.js +15 -3
- package/dist/tests/core/changes/list-paging.test.js +131 -110
- package/dist/tests/core/changes/list.test.js +133 -83
- package/dist/tests/core/changes/receipt.test.js +18 -6
- package/dist/tests/core/diagnostics/provider-checks.test.js +3 -0
- package/dist/tests/core/init/steps-registry-sync.test.js +6 -8
- package/dist/tests/core/registry-git.test.js +153 -0
- package/dist/tests/helpers/commit-seeded-registry.js +18 -0
- package/dist/tests/integration/codex-provider-isolated.test.js +7 -2
- package/dist/tests/integration/copilot-init-isolated.test.js +7 -4
- package/dist/tests/structural/active-documentation.test.js +0 -41
- package/dist/tests/structural/bundle-skill-reference-contract.test.js +0 -12
- package/dist/tests/structural/cli-reference-matches-commander.test.js +94 -0
- package/dist/tests/structural/native-release-artifacts.test.js +116 -107
- package/dist/tests/structural/sensor-documentation-contract.test.js +0 -47
- package/dist/tests/structural/sensor-portability-contract.test.js +0 -7
- package/dist/tests/structural/simple-git-v4-pin.test.js +49 -0
- package/package.json +2 -2
- package/prebuilds/darwin-arm64/secure_fs.node +0 -0
- package/prebuilds/darwin-x64/secure_fs.node +0 -0
- package/prebuilds/win32-arm64/secure_fs.node +0 -0
- package/prebuilds/win32-x64/secure_fs.node +0 -0
- package/dist/tests/structural/cloud-admission-docs-contract.test.js +0 -54
- package/dist/tests/structural/documentation-phase-is-mechanized.test.js +0 -29
- package/dist/tests/structural/operator-environments-docs-contract.test.js +0 -70
- package/dist/tests/structural/process-model-description-contract.test.js +0 -34
- package/dist/tests/structural/project-declared-sensors-docs-contract.test.js +0 -104
- package/dist/tests/structural/r4-plan-currentness-docs.test.js +0 -37
- package/dist/tests/structural/sensor-execution-optimization-docs-contract.test.js +0 -102
- package/dist/tests/structural/sensor-slice-decision-contract.test.js +0 -63
|
@@ -57,20 +57,46 @@ afterEach(() => expect(listReal()).toEqual(realBefore));
|
|
|
57
57
|
const UNSAFE = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/;
|
|
58
58
|
let repo;
|
|
59
59
|
let template;
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
/** The template plus `buildHistory`, built once: `history()` starts a test from a copy of it (no git process). */
|
|
61
|
+
let historic;
|
|
62
|
+
beforeAll(() => {
|
|
63
|
+
template = (0, helpers_1.makeRepo)();
|
|
64
|
+
historic = (0, helpers_1.cloneRepo)(template);
|
|
65
|
+
buildHistory(historic);
|
|
66
|
+
});
|
|
67
|
+
afterAll(() => { historic.cleanup(); template.cleanup(); });
|
|
62
68
|
beforeEach(() => { repo = (0, helpers_1.cloneRepo)(template); });
|
|
63
69
|
afterEach(() => { jest.restoreAllMocks(); repo.cleanup(); });
|
|
64
70
|
const FIRST = '2026-10-16-first';
|
|
71
|
+
function buildHistory(into) {
|
|
72
|
+
into.commit({ 'old.ts': '1' }, 'feat: before adoption', '2026-10-16T09:00:00Z');
|
|
73
|
+
into.commit({ [(0, list_fixtures_1.recordPath)(FIRST)]: (0, list_fixtures_1.record)(FIRST), 'a.ts': '1' }, 'feat: first (#10)', '2026-10-16T20:00:00Z');
|
|
74
|
+
into.commit({ 'b.ts': '1' }, 'fix: no record', '2026-10-16T21:00:00Z');
|
|
75
|
+
into.commit({ 'c.ts': '1' }, 'chore(release): v1.0.0 [skip ci]\n\nChange-Record: none', '2026-10-16T22:00:00Z');
|
|
76
|
+
into.commit({ [(0, list_fixtures_1.recordPath)('2026-10-17-bad')]: '---\nschema: nope\n---\n' }, 'feat: bad record', '2026-10-17T01:00:00Z');
|
|
77
|
+
into.gitAt('2026-10-17T02:00:00Z', 'tag', '-a', 'v1.0.0', '-m', 'v1.0.0');
|
|
78
|
+
}
|
|
79
|
+
/** The repository after `buildHistory` (a copy of the prebuilt one). */
|
|
65
80
|
function history() {
|
|
66
|
-
repo.
|
|
67
|
-
repo
|
|
68
|
-
repo.commit({ 'b.ts': '1' }, 'fix: no record', '2026-10-16T21:00:00Z');
|
|
69
|
-
repo.commit({ 'c.ts': '1' }, 'chore(release): v1.0.0 [skip ci]\n\nChange-Record: none', '2026-10-16T22:00:00Z');
|
|
70
|
-
repo.commit({ [(0, list_fixtures_1.recordPath)('2026-10-17-bad')]: '---\nschema: nope\n---\n' }, 'feat: bad record', '2026-10-17T01:00:00Z');
|
|
71
|
-
repo.gitAt('2026-10-17T02:00:00Z', 'tag', '-a', 'v1.0.0', '-m', 'v1.0.0');
|
|
81
|
+
repo.cleanup();
|
|
82
|
+
repo = (0, helpers_1.cloneRepo)(historic);
|
|
72
83
|
}
|
|
73
84
|
const run = (opts = {}, deps = {}) => (0, changes_1.runChangesList)(repo.root, opts, { now: list_fixtures_1.now, ...deps });
|
|
85
|
+
const sharedRuns = new Map();
|
|
86
|
+
/**
|
|
87
|
+
* `run(opts)` on the untouched prebuilt history. The command is read-only (R5.7, asserted below), so each distinct run is made once
|
|
88
|
+
* and its result shared by the tests that only read it.
|
|
89
|
+
*/
|
|
90
|
+
function onHistory(opts) {
|
|
91
|
+
const key = JSON.stringify(Object.entries(opts).sort(([a], [b]) => a.localeCompare(b)));
|
|
92
|
+
let r = sharedRuns.get(key);
|
|
93
|
+
if (r === undefined) {
|
|
94
|
+
r = (0, changes_1.runChangesList)(historic.root, opts, { now: list_fixtures_1.now });
|
|
95
|
+
sharedRuns.set(key, r);
|
|
96
|
+
}
|
|
97
|
+
return r;
|
|
98
|
+
}
|
|
99
|
+
const historyJson = () => JSON.parse(onHistory({ since: '48h', format: 'json' }).stdout);
|
|
74
100
|
const json = (opts = {}, deps = {}) => JSON.parse(run({ since: '48h', format: 'json', ...opts }, deps).stdout);
|
|
75
101
|
const failure = (r, code) => {
|
|
76
102
|
expect(r.code).toBe(2);
|
|
@@ -209,8 +235,7 @@ describe('awm changes list: adoption (R5.4)', () => {
|
|
|
209
235
|
});
|
|
210
236
|
describe('awm changes list: classification and derived fields (R5.1, R5.3, R5.4, R5.5)', () => {
|
|
211
237
|
test('classifies and derives', () => {
|
|
212
|
-
|
|
213
|
-
const out = json();
|
|
238
|
+
const out = historyJson();
|
|
214
239
|
const byClass = Object.fromEntries(out.changes.map((c) => [c.subject, c.classification]));
|
|
215
240
|
expect(byClass).toEqual({ 'feat: first (#10)': 'recorded', 'fix: no record': 'missing', 'chore(release): v1.0.0 [skip ci]': 'exempt', 'feat: bad record': 'invalid-record' });
|
|
216
241
|
const first = out.changes.find((c) => c.subject === 'feat: first (#10)');
|
|
@@ -219,8 +244,7 @@ describe('awm changes list: classification and derived fields (R5.1, R5.3, R5.4,
|
|
|
219
244
|
expect(out.repo).toBeNull();
|
|
220
245
|
});
|
|
221
246
|
test('pre-adoption commits are omitted', () => {
|
|
222
|
-
|
|
223
|
-
expect(JSON.stringify(json())).not.toContain('before adoption');
|
|
247
|
+
expect(JSON.stringify(historyJson())).not.toContain('before adoption');
|
|
224
248
|
});
|
|
225
249
|
test('repo identity strips credentials', () => {
|
|
226
250
|
history();
|
|
@@ -291,28 +315,24 @@ describe('awm changes list: mixed invalid commits, odd dates and shallow clones
|
|
|
291
315
|
});
|
|
292
316
|
describe('awm changes list: formats (R5.6)', () => {
|
|
293
317
|
test('json is one document on one line', () => {
|
|
294
|
-
|
|
295
|
-
const r = run({ since: '48h', format: 'json' });
|
|
318
|
+
const r = onHistory({ since: '48h', format: 'json' });
|
|
296
319
|
expect(r.stdout.split('\n')).toHaveLength(2);
|
|
297
320
|
expect(Object.keys(JSON.parse(r.stdout))).toEqual(['schema', 'repo', 'base', 'since', 'adopted', 'truncated', 'omitted', 'changes']);
|
|
298
321
|
expect(JSON.parse(r.stdout).adopted).toBe(true);
|
|
299
322
|
});
|
|
300
323
|
test('voice speaks recorded changes and counts the rest', () => {
|
|
301
|
-
|
|
302
|
-
const r = run({ since: '48h', format: 'voice' });
|
|
324
|
+
const r = onHistory({ since: '48h', format: 'voice' });
|
|
303
325
|
expect(r.stdout).toContain('Primer cambio. feat, verificado, 2 validaciones manuales pendientes.');
|
|
304
326
|
expect(r.stdout).toMatch(/1 cambio\(s\) sin record y 1 con record inválido/);
|
|
305
327
|
});
|
|
306
328
|
test('text is a table with the classification of each change', () => {
|
|
307
|
-
|
|
308
|
-
const out = run({ since: '48h' }).stdout;
|
|
329
|
+
const out = onHistory({ since: '48h' }).stdout;
|
|
309
330
|
for (const word of ['recorded', 'missing', 'exempt', 'invalid-record', 'v1.0.0', '#10'])
|
|
310
331
|
expect(out).toContain(word);
|
|
311
332
|
expect(out.split('\n')[0]).toMatch(/^WHEN\s+CLASSIFICATION/);
|
|
312
333
|
});
|
|
313
334
|
test('text is the default format', () => {
|
|
314
|
-
|
|
315
|
-
expect(run({ since: '48h' }).stdout).toBe(run({ since: '48h', format: 'text' }).stdout);
|
|
335
|
+
expect(onHistory({ since: '48h' }).stdout).toBe(onHistory({ since: '48h', format: 'text' }).stdout);
|
|
316
336
|
});
|
|
317
337
|
test('an empty window says so in text', () => {
|
|
318
338
|
history();
|
|
@@ -328,9 +348,8 @@ describe('awm changes list: formats (R5.6)', () => {
|
|
|
328
348
|
expect(run({ since: '48h', format: 'voice' }, deps).stdout).toMatch(/Hay 2 cambio\(s\) más antiguo\(s\) sin mostrar/);
|
|
329
349
|
});
|
|
330
350
|
test('no format prints anything on stderr when it succeeds', () => {
|
|
331
|
-
history();
|
|
332
351
|
for (const format of ['text', 'json', 'voice'])
|
|
333
|
-
expect(
|
|
352
|
+
expect(onHistory({ since: '48h', format }).stderr).toBe('');
|
|
334
353
|
});
|
|
335
354
|
});
|
|
336
355
|
describe('awm changes list: read-only (R5.7)', () => {
|
|
@@ -425,9 +425,20 @@ describe('reap — limpieza explicita con identidad validada (R2.2)', () => {
|
|
|
425
425
|
expect(m.aliveWithIdentity).toBe(false);
|
|
426
426
|
});
|
|
427
427
|
test('planReap reporta aliveWithIdentity: true para un proceso genuinamente vivo', () => {
|
|
428
|
-
|
|
428
|
+
// Same full-spawn retry as process.test.ts: on windows-11-arm (and
|
|
429
|
+
// historically windows-latest) a freshly spawned `node -e` can die
|
|
430
|
+
// before OpenProcess/pidExistsNative sees it. Re-polling the same pid
|
|
431
|
+
// never helps; a fresh instance can. Three failures still fail loudly.
|
|
432
|
+
const { spawnStructured, refIsAlive } = require('../../../src/core/journal/process');
|
|
429
433
|
const s = (0, types_1.emptyState)('r');
|
|
430
|
-
|
|
434
|
+
let { child, ref } = spawnStructured(['node', '-e', 'setTimeout(()=>{}, 5000)'], process.cwd(), 'nLive');
|
|
435
|
+
for (let attempt = 1; attempt < 3 && !refIsAlive(ref); attempt++) {
|
|
436
|
+
try {
|
|
437
|
+
child.kill('SIGKILL');
|
|
438
|
+
}
|
|
439
|
+
catch { /* already gone */ }
|
|
440
|
+
({ child, ref } = spawnStructured(['node', '-e', 'setTimeout(()=>{}, 5000)'], process.cwd(), 'nLive'));
|
|
441
|
+
}
|
|
431
442
|
s.jobs['vivo'] = job({ id: 'vivo', executionState: 'running', processRef: ref });
|
|
432
443
|
try {
|
|
433
444
|
const plan = (0, reap_1.planReap)(s);
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
// #238 S2 / SG-7 — soft (add/list) vs hard (sync/update/init/registry add) sync failure UX.
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const os_1 = __importDefault(require("os"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const registry_sync_report_1 = require("../../src/core/registry-sync-report");
|
|
11
|
+
describe('registry-sync-report pure helpers (#238 S2)', () => {
|
|
12
|
+
it('formatClassifiedRegistryFailure includes name, remote, class, fatal, remedy — never URL userinfo', () => {
|
|
13
|
+
const token = 'ghp_TESTSECRET_DO_NOT_ECHO';
|
|
14
|
+
const text = (0, registry_sync_report_1.formatClassifiedRegistryFailure)({
|
|
15
|
+
name: 'baseline',
|
|
16
|
+
remote: `https://${token}@example.test/baseline.git`,
|
|
17
|
+
err: new Error(`fatal: could not read Username for 'https://${token}@example.test': terminal prompts disabled`),
|
|
18
|
+
});
|
|
19
|
+
expect(text).toContain('baseline');
|
|
20
|
+
expect(text).toContain('example.test/baseline.git');
|
|
21
|
+
expect(text).toMatch(/authentication/i);
|
|
22
|
+
expect(text).toMatch(/terminal prompts disabled/i);
|
|
23
|
+
expect(text).toMatch(/credential\.helper/);
|
|
24
|
+
expect(text).not.toContain(token);
|
|
25
|
+
expect(text).not.toMatch(/ghp_/);
|
|
26
|
+
});
|
|
27
|
+
it('formatRegistrySyncSoftFailure states cached version plus SG-6 cause/remedy', () => {
|
|
28
|
+
const classified = (0, registry_sync_report_1.formatClassifiedRegistryFailure)({
|
|
29
|
+
name: 'docs',
|
|
30
|
+
remote: 'https://example.test/docs.git',
|
|
31
|
+
err: new Error('fatal: unable to access: Could not resolve host: example.test'),
|
|
32
|
+
});
|
|
33
|
+
const soft = (0, registry_sync_report_1.formatRegistrySyncSoftFailure)({
|
|
34
|
+
name: 'docs',
|
|
35
|
+
cachedVersion: '1.4.0',
|
|
36
|
+
classifiedMessage: classified,
|
|
37
|
+
});
|
|
38
|
+
expect(soft).toMatch(/cached version v1\.4\.0/i);
|
|
39
|
+
expect(soft).toContain('docs');
|
|
40
|
+
expect(soft).toMatch(/network/i);
|
|
41
|
+
expect(soft).toMatch(/Could not resolve host/i);
|
|
42
|
+
expect(soft).toMatch(/remedy:/);
|
|
43
|
+
// B3: soft line must not say "sync failed" twice before the classified clause.
|
|
44
|
+
expect(soft.match(/sync failed/gi)?.length ?? 0).toBe(1);
|
|
45
|
+
});
|
|
46
|
+
it('formatRegistrySyncSoftFailure uses unknown when cachedVersion is null', () => {
|
|
47
|
+
const soft = (0, registry_sync_report_1.formatRegistrySyncSoftFailure)({
|
|
48
|
+
name: 'docs',
|
|
49
|
+
cachedVersion: null,
|
|
50
|
+
classifiedMessage: 'registry docs sync failed (class=network); remedy: check connectivity',
|
|
51
|
+
});
|
|
52
|
+
expect(soft).toMatch(/cached version unknown/i);
|
|
53
|
+
});
|
|
54
|
+
it('hardFailOnAnyRegistrySyncError is true when any result is action:error', () => {
|
|
55
|
+
const ok = [{ name: 'baseline', action: 'pulled', version: 'v1.0.0' }];
|
|
56
|
+
const mixed = [
|
|
57
|
+
{ name: 'baseline', action: 'pulled', version: 'v1.0.0' },
|
|
58
|
+
{ name: 'docs', action: 'error', error: 'network unreachable' },
|
|
59
|
+
];
|
|
60
|
+
expect((0, registry_sync_report_1.hardFailOnAnyRegistrySyncError)(ok)).toBe(false);
|
|
61
|
+
expect((0, registry_sync_report_1.hardFailOnAnyRegistrySyncError)(mixed)).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
it('formatSoftRegistrySyncFailures emits only usable errored registries with cached version', async () => {
|
|
64
|
+
const results = [
|
|
65
|
+
{ name: 'ok', action: 'pulled', version: 'v1.0.0' },
|
|
66
|
+
{ name: 'stale', action: 'error', error: 'registry stale sync failed (class=network); remedy: check connectivity' },
|
|
67
|
+
{ name: 'gone', action: 'error', error: 'registry gone sync failed (class=not-found); remedy: verify the remote URL' },
|
|
68
|
+
];
|
|
69
|
+
const lines = await (0, registry_sync_report_1.formatSoftRegistrySyncFailures)(results, {
|
|
70
|
+
isUsable: (name) => name === 'stale',
|
|
71
|
+
cachedVersion: (name) => (name === 'stale' ? '2.0.0' : null),
|
|
72
|
+
});
|
|
73
|
+
expect(lines).toHaveLength(1);
|
|
74
|
+
expect(lines[0]).toMatch(/stale/);
|
|
75
|
+
expect(lines[0]).toMatch(/cached version v2\.0\.0/i);
|
|
76
|
+
expect(lines[0]).toMatch(/class=network/);
|
|
77
|
+
expect(lines.join('\n')).not.toMatch(/\bgone\b/);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
describe('awm sync / update hard-exit on sync error (#238 S2 / SG-7)', () => {
|
|
81
|
+
const realHome = process.env.HOME;
|
|
82
|
+
const realAwmHome = process.env.AWM_HOME;
|
|
83
|
+
let tmpHome;
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-sync-honesty-'));
|
|
86
|
+
process.env.HOME = tmpHome;
|
|
87
|
+
process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
|
|
88
|
+
fs_1.default.mkdirSync(process.env.AWM_HOME, { recursive: true });
|
|
89
|
+
jest.resetModules();
|
|
90
|
+
jest.doMock('@clack/prompts', () => ({
|
|
91
|
+
confirm: jest.fn(),
|
|
92
|
+
isCancel: jest.fn(),
|
|
93
|
+
intro: jest.fn(),
|
|
94
|
+
outro: jest.fn(),
|
|
95
|
+
spinner: jest.fn(() => ({ start: jest.fn(), stop: jest.fn() })),
|
|
96
|
+
}));
|
|
97
|
+
});
|
|
98
|
+
afterEach(() => {
|
|
99
|
+
fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
|
|
100
|
+
if (realHome === undefined)
|
|
101
|
+
delete process.env.HOME;
|
|
102
|
+
else
|
|
103
|
+
process.env.HOME = realHome;
|
|
104
|
+
if (realAwmHome === undefined)
|
|
105
|
+
delete process.env.AWM_HOME;
|
|
106
|
+
else
|
|
107
|
+
process.env.AWM_HOME = realAwmHome;
|
|
108
|
+
jest.resetModules();
|
|
109
|
+
jest.dontMock('@clack/prompts');
|
|
110
|
+
});
|
|
111
|
+
function writePrefs() {
|
|
112
|
+
const { savePreferences } = require('../../src/utils/config');
|
|
113
|
+
savePreferences({
|
|
114
|
+
defaultAgent: 'claude-code',
|
|
115
|
+
enabledAgents: ['claude-code'],
|
|
116
|
+
installMethod: 'symlink',
|
|
117
|
+
defaultScope: 'local',
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
function seedUsableDocsRegistry() {
|
|
121
|
+
const registriesDir = path_1.default.join(process.env.AWM_HOME, 'registries');
|
|
122
|
+
fs_1.default.mkdirSync(path_1.default.join(registriesDir, 'docs', 'skills'), { recursive: true });
|
|
123
|
+
fs_1.default.writeFileSync(path_1.default.join(registriesDir, 'docs', 'skills', 'x.md'), '# x\n');
|
|
124
|
+
fs_1.default.writeFileSync(path_1.default.join(process.env.AWM_HOME, 'registries.json'), JSON.stringify([{ name: 'docs', remote: 'https://example.test/docs.git' }]));
|
|
125
|
+
}
|
|
126
|
+
it('runSyncCore returns code !== 0 when any registry result is action:error (even if usable on disk)', async () => {
|
|
127
|
+
writePrefs();
|
|
128
|
+
seedUsableDocsRegistry();
|
|
129
|
+
const project = path_1.default.join(tmpHome, 'proj');
|
|
130
|
+
fs_1.default.mkdirSync(path_1.default.join(project, '.awm'), { recursive: true });
|
|
131
|
+
fs_1.default.writeFileSync(path_1.default.join(project, '.awm', 'profile.json'), JSON.stringify({ version: 1, extensions: [], agents: ['claude-code'] }));
|
|
132
|
+
const reconcile = jest.fn(() => []);
|
|
133
|
+
const { runSyncCore } = require('../../src/commands/sync');
|
|
134
|
+
const result = await runSyncCore({ cwd: project }, {
|
|
135
|
+
syncRegistries: async () => [
|
|
136
|
+
{ name: 'docs', action: 'error', error: 'network unreachable' },
|
|
137
|
+
],
|
|
138
|
+
verifyMinCliVersions: () => [],
|
|
139
|
+
verifyProjectPins: async () => [],
|
|
140
|
+
syncProfile: () => ({ extensions: [], installed: [], skipped: [], transactionIds: [] }),
|
|
141
|
+
reconcileProjectSkillLinks: reconcile,
|
|
142
|
+
});
|
|
143
|
+
expect(result.code).not.toBe(0);
|
|
144
|
+
// A1: later stages still run when usable content remains.
|
|
145
|
+
expect(reconcile).toHaveBeenCalled();
|
|
146
|
+
});
|
|
147
|
+
it('runUpdateCore returns code !== 0 when a registry fails even with usable on-disk content', async () => {
|
|
148
|
+
writePrefs();
|
|
149
|
+
seedUsableDocsRegistry();
|
|
150
|
+
const regenerate = jest.fn(() => []);
|
|
151
|
+
const planReconciliation = jest.fn(() => ({ operations: [], records: [], reports: [] }));
|
|
152
|
+
const applyInstallPlan = jest.fn(() => ({ installed: [], skipped: [], transactionId: 'tx', modifiedFiles: [] }));
|
|
153
|
+
const offerSelfUpdate = jest.fn(async () => { });
|
|
154
|
+
const { runUpdateCore, updateOutro } = require('../../src/commands/update');
|
|
155
|
+
const result = await runUpdateCore({}, {
|
|
156
|
+
syncRegistries: async () => [
|
|
157
|
+
{ name: 'docs', action: 'error', error: 'network unreachable' },
|
|
158
|
+
],
|
|
159
|
+
verifyMinCliVersions: () => [],
|
|
160
|
+
regenerateGlobalContext: regenerate,
|
|
161
|
+
planReconciliation,
|
|
162
|
+
applyInstallPlan,
|
|
163
|
+
resyncInstalledHooks: () => [],
|
|
164
|
+
offerSelfUpdate,
|
|
165
|
+
});
|
|
166
|
+
expect(result.code).not.toBe(0);
|
|
167
|
+
expect(result.registries.failed).toEqual(['docs']);
|
|
168
|
+
// A1: pipeline continues after sync error when content is usable.
|
|
169
|
+
expect(regenerate).toHaveBeenCalled();
|
|
170
|
+
expect(planReconciliation).toHaveBeenCalled();
|
|
171
|
+
expect(applyInstallPlan).toHaveBeenCalled();
|
|
172
|
+
expect(offerSelfUpdate).toHaveBeenCalled();
|
|
173
|
+
expect(updateOutro(result)).toMatch(/Finished with registry sync errors|Update failed/i);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -44,6 +44,7 @@ const exec_1 = require("../../../src/commands/sensors/exec");
|
|
|
44
44
|
const result_1 = require("../../../src/commands/sensors/result");
|
|
45
45
|
const baseline_1 = require("../../../src/commands/sensors/baseline");
|
|
46
46
|
const prepare_1 = require("../../../src/commands/sensors/prepare");
|
|
47
|
+
const fixture_cleanup_1 = require("../../helpers/fixture-cleanup");
|
|
47
48
|
const sensor = (overrides = {}) => ({
|
|
48
49
|
name: 'lint',
|
|
49
50
|
command: { kind: 'legacy', value: 'node -e "setTimeout(() => {}, 1000)"' },
|
|
@@ -401,7 +402,8 @@ describe('runStructuredCommand — Windows local native binaries', () => {
|
|
|
401
402
|
delete process.env.PATHEXT;
|
|
402
403
|
else
|
|
403
404
|
process.env.PATHEXT = savedPathExt;
|
|
404
|
-
|
|
405
|
+
// Windows keeps a just-executed copy of node locked for a moment (EBUSY on unlink).
|
|
406
|
+
(0, fixture_cleanup_1.removeFixtureTree)(dir);
|
|
405
407
|
});
|
|
406
408
|
it('executes a contained native executable with literal shell-sensitive argv', async () => {
|
|
407
409
|
const executable = path_1.default.join(dir, 'node_modules', '.bin', 'fixture.exe');
|
|
@@ -164,9 +164,9 @@ describe('awm update — honest outcome', () => {
|
|
|
164
164
|
expect(result.code).toBe(1);
|
|
165
165
|
expect(result.registries.failed).toEqual(['baseline']);
|
|
166
166
|
});
|
|
167
|
-
it('un registry que falló
|
|
168
|
-
//
|
|
169
|
-
//
|
|
167
|
+
it('un registry que falló falla cerrado aunque conserve contenido en disco (SG-7)', async () => {
|
|
168
|
+
// Pre-SG-7 update warn-and-continued on usable cache; #238 SG-7 requires
|
|
169
|
+
// non-zero exit for init/update/sync/registry add on any sync failure.
|
|
170
170
|
const registriesDir = path_1.default.join(process.env.AWM_HOME, 'registries');
|
|
171
171
|
fs_1.default.mkdirSync(path_1.default.join(registriesDir, 'docs', 'skills'), { recursive: true });
|
|
172
172
|
fs_1.default.writeFileSync(path_1.default.join(process.env.AWM_HOME, 'registries.json'), JSON.stringify([{ name: 'docs', remote: 'https://example.test/docs.git' }]));
|
|
@@ -177,12 +177,12 @@ describe('awm update — honest outcome', () => {
|
|
|
177
177
|
{ name: 'docs', action: 'error', error: 'network unreachable' },
|
|
178
178
|
],
|
|
179
179
|
}));
|
|
180
|
-
expect(result.code).toBe(
|
|
180
|
+
expect(result.code).toBe(1);
|
|
181
181
|
expect(result.registries).toEqual({ configured: 2, synced: 1, failed: ['docs'] });
|
|
182
|
-
|
|
183
|
-
expect(
|
|
184
|
-
expect(
|
|
185
|
-
expect(
|
|
182
|
+
expect(updateOutro(result)).toMatch(/Finished with registry sync errors/);
|
|
183
|
+
expect(updateOutro(result)).toMatch(/1\/2/);
|
|
184
|
+
expect(updateOutro(result)).toMatch(/docs/);
|
|
185
|
+
expect(errSpy.mock.calls.flat().join('\n')).toMatch(/docs/);
|
|
186
186
|
});
|
|
187
187
|
});
|
|
188
188
|
describe('awm update — nunca se cuelga esperando a un humano que no está', () => {
|
|
@@ -758,21 +758,37 @@ describe('supervisor loop', () => {
|
|
|
758
758
|
// subagent kept an idle leader while the work ran in processes outside its
|
|
759
759
|
// process group, and the supervisor entered a false custody. Work in the
|
|
760
760
|
// descendant tree is activity: observe, never custody, while it moves.
|
|
761
|
+
// The tree must move deterministically between the two ticks. A `while :; do
|
|
762
|
+
// sleep 0.02; done` loop leaves instants with no `sleep` child, so under CPU
|
|
763
|
+
// load two samples 150 ms apart could read identical (~1% of pairs) and the
|
|
764
|
+
// supervisor entered a real custody: a flaky red on linux-arm64. Each worker
|
|
765
|
+
// child lives 30 s, and the test itself makes the tree grow by one between ticks.
|
|
761
766
|
itPosix('espera sana: lider ocioso + descendiente fuera del grupo trabajando => observa, no custodia (R4.2)', async () => {
|
|
762
767
|
initUnattendedFixture(repo);
|
|
763
768
|
(0, generations_1.beginGeneration)(repo, 'main');
|
|
764
|
-
const
|
|
765
|
-
const
|
|
769
|
+
const trigger = path_1.default.join(repo, 'grow-descendant');
|
|
770
|
+
const worker = [
|
|
771
|
+
"const { spawn } = require('child_process'); const fs = require('fs');",
|
|
772
|
+
"const grow = () => spawn('sleep', ['30'], { detached: true, stdio: 'ignore' }).unref();",
|
|
773
|
+
'grow();',
|
|
774
|
+
'const poll = setInterval(() => { if (fs.existsSync(process.argv[1])) { clearInterval(poll); grow(); } }, 10);',
|
|
775
|
+
'setTimeout(() => {}, 20000);',
|
|
776
|
+
].join(' ');
|
|
777
|
+
const { child, ref } = (0, process_1.spawnStructured)(['node', '-e', worker, trigger], process.cwd(), 'nWait');
|
|
766
778
|
try {
|
|
767
779
|
let s = (0, store_1.readJournal)(repo, 'main').state;
|
|
768
780
|
(0, generations_1.activeGeneration)(s).processRef = ref;
|
|
769
781
|
s.controllerHeartbeatAt = new Date(Date.now() - 3600000).toISOString(); // heartbeat vencido: bloqueado en la espera
|
|
770
782
|
(0, store_1.writeJournal)(repo, 'main', s);
|
|
771
|
-
|
|
783
|
+
const descendants = () => (0, process_1.activitySnapshot)(ref)?.descendants ?? '0:none';
|
|
784
|
+
await until(() => descendants().startsWith('1:'));
|
|
772
785
|
const cfg = { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, provider: 'codex', heartbeatTimeoutMs: 1, activityWindowMs: 50, tickMs: 20 };
|
|
773
786
|
const sup = new supervisor_1.Supervisor(repo, 'main', cfg, fakeSpawner, undefined, admitted);
|
|
774
787
|
await sup.tick();
|
|
775
|
-
|
|
788
|
+
const firstTick = Date.now();
|
|
789
|
+
fs_1.default.writeFileSync(trigger, ''); // el arbol crece: trabajo real fuera del grupo
|
|
790
|
+
await until(() => descendants().startsWith('2:'));
|
|
791
|
+
await new Promise((r) => setTimeout(r, Math.max(0, 150 - (Date.now() - firstTick)))); // mas que la ventana, pero el arbol se movio
|
|
776
792
|
expect(await sup.tick()).not.toBe('custody');
|
|
777
793
|
const after = (0, store_1.readJournal)(repo, 'main').state;
|
|
778
794
|
expect(after.cycle.status).toBe('IN_PROGRESS');
|
|
@@ -780,7 +796,7 @@ describe('supervisor loop', () => {
|
|
|
780
796
|
expect(child.killed).toBe(false);
|
|
781
797
|
}
|
|
782
798
|
finally {
|
|
783
|
-
//
|
|
799
|
+
// Each detached child leads its own group: kill that group, never a pattern match.
|
|
784
800
|
let shells = [];
|
|
785
801
|
try {
|
|
786
802
|
shells = (0, child_process_1.execFileSync)('pgrep', ['-P', String(ref.pid)], { encoding: 'utf8' }).split('\n').filter(Boolean).map(Number);
|
|
@@ -67,19 +67,25 @@ function emptyGitConfig(home) {
|
|
|
67
67
|
fs_1.default.writeFileSync(file, '');
|
|
68
68
|
return file;
|
|
69
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Every test git child: no signing, and no automatic maintenance after a commit. A newer git runs that maintenance detached in
|
|
72
|
+
* the background, where its lock file under `.git/objects` appears and vanishes while a test copies or removes the repository
|
|
73
|
+
* (`cloneRepo` failed with ENOENT on macOS with git 2.55).
|
|
74
|
+
*/
|
|
75
|
+
const GIT_TEST_CONFIG = ['-c', 'commit.gpgsign=false', '-c', 'maintenance.auto=false', '-c', 'gc.auto=0'];
|
|
70
76
|
/** The git runner of a repository: identity and config isolation in an explicit environment, optional explicit dates. */
|
|
71
77
|
function gitApi(root, home) {
|
|
72
78
|
const env = {
|
|
73
79
|
...process.env, HOME: home, GIT_CONFIG_GLOBAL: path_1.default.join(home, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1',
|
|
74
80
|
GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t',
|
|
75
81
|
};
|
|
76
|
-
const run = (extra, args) => (0, child_process_1.execFileSync)('git', [
|
|
82
|
+
const run = (extra, args) => (0, child_process_1.execFileSync)('git', [...GIT_TEST_CONFIG, ...args], { cwd: root, encoding: 'utf8', env: { ...env, ...extra } }).trim();
|
|
77
83
|
const dates = (date) => {
|
|
78
84
|
const stamp = `${Math.floor(Date.parse(date) / 1000)} +0000`;
|
|
79
85
|
return { GIT_AUTHOR_DATE: stamp, GIT_COMMITTER_DATE: stamp };
|
|
80
86
|
};
|
|
81
87
|
const git = (...args) => run({}, args);
|
|
82
|
-
const gitInput = (input, ...args) => (0, child_process_1.execFileSync)('git', [
|
|
88
|
+
const gitInput = (input, ...args) => (0, child_process_1.execFileSync)('git', [...GIT_TEST_CONFIG, ...args], { cwd: root, encoding: 'utf8', env, input }).trim();
|
|
83
89
|
const gitAt = (date, ...args) => run(dates(date), args);
|
|
84
90
|
const commit = (files, message, date) => {
|
|
85
91
|
for (const [rel, content] of Object.entries(files)) {
|
|
@@ -144,7 +150,13 @@ Object.defineProperty(exports, "posixOnly", { enumerable: true, get: function ()
|
|
|
144
150
|
*/
|
|
145
151
|
function cloneRepo(template) {
|
|
146
152
|
const root = fs_1.default.realpathSync.native(fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-chg-')));
|
|
147
|
-
|
|
153
|
+
try {
|
|
154
|
+
fs_1.default.cpSync(template.root, root, { recursive: true });
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
fs_1.default.rmSync(root, { recursive: true, force: true });
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
148
160
|
const { git, gitInput, gitAt, commit } = gitApi(root, template.home);
|
|
149
161
|
return { root, home: template.home, git, gitInput, gitAt, commit, cleanup: () => fs_1.default.rmSync(root, { recursive: true, force: true }) };
|
|
150
162
|
}
|