@agfpd/iapeer 0.1.2 → 0.2.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/package.json +9 -2
- package/src/cli/cli.test.ts +23 -2
- package/src/cli/index.ts +90 -2
- package/src/core/constants.ts +7 -1
- package/src/daemon/main.ts +7 -2
- package/src/lifecycle/eventlog.test.ts +114 -0
- package/src/lifecycle/eventlog.ts +133 -0
- package/src/lifecycle/index.ts +292 -56
- package/src/lifecycle/lifecycle.test.ts +208 -63
- package/src/registry/registry.test.ts +33 -1
|
@@ -1,25 +1,32 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
-
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
3
3
|
import { tmpdir } from 'os'
|
|
4
4
|
import { join } from 'path'
|
|
5
5
|
import {
|
|
6
6
|
attachPeer,
|
|
7
|
-
|
|
7
|
+
clearNewEager,
|
|
8
8
|
clearStopped,
|
|
9
9
|
composeFirstMessage,
|
|
10
|
+
countRecentDeaths,
|
|
10
11
|
folderLaunch,
|
|
11
|
-
|
|
12
|
+
hasIdleReaped,
|
|
13
|
+
hasNewEager,
|
|
12
14
|
isLaunchdManaged,
|
|
13
15
|
isStopped,
|
|
14
16
|
lastActiveRuntime,
|
|
15
17
|
loadLifecycleConfig,
|
|
18
|
+
readDeaths,
|
|
19
|
+
readTopic,
|
|
20
|
+
recordDeath,
|
|
16
21
|
resolveWakeMode,
|
|
17
22
|
resolveWakeRuntime,
|
|
18
|
-
|
|
23
|
+
setIdleReaped,
|
|
24
|
+
setNewEager,
|
|
19
25
|
setStopped,
|
|
20
26
|
superviseTick,
|
|
21
27
|
wakeOrSpawn,
|
|
22
28
|
withWakeLock,
|
|
29
|
+
writeTopic,
|
|
23
30
|
type LifecycleConfig,
|
|
24
31
|
} from './index.ts'
|
|
25
32
|
import { upsertPeer, type PeerRecord } from '../registry/index.ts'
|
|
@@ -72,10 +79,12 @@ describe('resolveWakeRuntime (H5)', () => {
|
|
|
72
79
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
73
80
|
|
|
74
81
|
describe('isLaunchdManaged (H4 detector, live read-only)', () => {
|
|
75
|
-
test('a launchd-managed peer (
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
82
|
+
test('a launchd-managed peer (timer has com.iapeer.timer.plist) → true', () => {
|
|
83
|
+
// Post foundation-migration the always-on INFRA peers (timer/watcher/arthur) keep
|
|
84
|
+
// their com.iapeer.<p>.plist; the daemon must treat them READ-ONLY (never
|
|
85
|
+
// wake/reap). This proves the detector fires on the live fleet. (boris and the
|
|
86
|
+
// agent peers became warm-on-demand — plist relocated — so they are NOT managed.)
|
|
87
|
+
expect(isLaunchdManaged('timer')).toBe(true)
|
|
79
88
|
})
|
|
80
89
|
|
|
81
90
|
test('a made-up daemon-owned name (no plist) → false', () => {
|
|
@@ -167,6 +176,7 @@ describe('superviseTick H4 guard', () => {
|
|
|
167
176
|
sockDir: '/tmp',
|
|
168
177
|
stateDir,
|
|
169
178
|
logDir: stateDir,
|
|
179
|
+
eventLogDir: stateDir, // isolate the decision log into the temp dir (no real-root leak)
|
|
170
180
|
bootDeadlineSecs: 1,
|
|
171
181
|
readyGateSecs: 1,
|
|
172
182
|
idleSecs: 1,
|
|
@@ -195,11 +205,17 @@ describe('superviseTick H4 guard', () => {
|
|
|
195
205
|
})
|
|
196
206
|
|
|
197
207
|
test('a no-plist peer with a dead session → reaped-gone, state removed', () => {
|
|
208
|
+
const c = cfg()
|
|
198
209
|
const id = writeState('iapeer-supgone') // no plist, no live tmux session
|
|
199
|
-
const out = superviseTick(
|
|
210
|
+
const out = superviseTick(c, { env: env(), nowMs: Date.now() })
|
|
200
211
|
const o = out.find(x => x.identity === id)
|
|
201
212
|
expect(o?.action).toBe('reaped-gone')
|
|
202
213
|
expect(existsSync(join(stateDir, `${id}.session`))).toBe(false)
|
|
214
|
+
// the decision leaves a DURABLE trace line (the observability contract) — and it
|
|
215
|
+
// lands in the SANDBOXED eventLogDir, never the real ~/.iapeer.
|
|
216
|
+
const logged = readFileSync(join(c.eventLogDir, 'lifecycle.log'), 'utf8')
|
|
217
|
+
expect(logged).toContain(`ev=supervise identity=${id} action=reaped-gone`)
|
|
218
|
+
expect(logged).toContain('outcome=fresh-next-msg')
|
|
203
219
|
})
|
|
204
220
|
|
|
205
221
|
test('empty state dir → no outcomes', () => {
|
|
@@ -338,54 +354,119 @@ describe('C2 initial_prompt (composeFirstMessage)', () => {
|
|
|
338
354
|
})
|
|
339
355
|
|
|
340
356
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
341
|
-
//
|
|
357
|
+
// resolveWakeMode — TARGET redesign (daemon decides fresh-vs-resume by DEATH CAUSE
|
|
358
|
+
// = .idle-reaped marker, plus peer-type/topic; NO agent-dropped fresh mark).
|
|
342
359
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
343
360
|
|
|
344
|
-
|
|
361
|
+
/** A temp cwd with a peer-profile; interfaces.telegram present → human-conversational. */
|
|
362
|
+
function profileCwd(human: boolean): string {
|
|
363
|
+
const cwd = mkdtempSync(join(tmpdir(), 'iapeer-wm-cwd-'))
|
|
364
|
+
mkdirSync(join(cwd, '.iapeer'), { recursive: true })
|
|
365
|
+
writeFileSync(
|
|
366
|
+
join(cwd, '.iapeer', 'peer-profile.json'),
|
|
367
|
+
JSON.stringify({
|
|
368
|
+
personality: 'p',
|
|
369
|
+
runtime: 'claude',
|
|
370
|
+
runtimes: ['claude'],
|
|
371
|
+
intelligence: human ? 'natural' : 'artificial',
|
|
372
|
+
...(human ? { interfaces: { telegram: { user_id: 1 } } } : {}),
|
|
373
|
+
}),
|
|
374
|
+
)
|
|
375
|
+
return cwd
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
describe('resolveWakeMode (TARGET: death-cause + peer-type/topic)', () => {
|
|
345
379
|
let stateDir: string
|
|
380
|
+
let cwds: string[]
|
|
346
381
|
beforeEach(() => {
|
|
347
382
|
stateDir = mkdtempSync(join(tmpdir(), 'iapeer-wakemode-'))
|
|
383
|
+
cwds = []
|
|
348
384
|
})
|
|
349
385
|
afterEach(() => {
|
|
350
386
|
rmSync(stateDir, { recursive: true, force: true })
|
|
387
|
+
for (const c of cwds) rmSync(c, { recursive: true, force: true })
|
|
351
388
|
})
|
|
352
389
|
const cfg = () => ({ stateDir } as LifecycleConfig)
|
|
390
|
+
const cwd = (human = false) => {
|
|
391
|
+
const c = profileCwd(human)
|
|
392
|
+
cwds.push(c)
|
|
393
|
+
return c
|
|
394
|
+
}
|
|
353
395
|
const hasTranscript = () => ({ ok: true, ref: 'uuid-1' })
|
|
354
396
|
const noTranscript = () => ({ ok: false, reason: 'no transcript to resume' })
|
|
355
397
|
|
|
356
|
-
|
|
357
|
-
|
|
398
|
+
// ── branch 1/2: explicit fresh / explicit resume (unchanged) ────────────────
|
|
399
|
+
test('argsResume=false (folder-launch) → FRESH', () => {
|
|
400
|
+
expect(resolveWakeMode(cfg(), 'claude-p', cwd(), false, hasTranscript)).toEqual({ resume: false, cause: 'folder-launch' })
|
|
358
401
|
})
|
|
359
|
-
test('
|
|
360
|
-
expect(resolveWakeMode(cfg(), 'claude-p',
|
|
402
|
+
test('argsResume=true (attach) + transcript → RESUME', () => {
|
|
403
|
+
expect(resolveWakeMode(cfg(), 'claude-p', cwd(), true, hasTranscript)).toEqual({ resume: true, resumeRef: 'uuid-1', cause: 'attach' })
|
|
361
404
|
})
|
|
362
|
-
test('
|
|
363
|
-
const m = resolveWakeMode(cfg(), 'claude-p',
|
|
405
|
+
test('argsResume=true + nothing to resume → FAIL-LOUD (failReason, no silent fresh)', () => {
|
|
406
|
+
const m = resolveWakeMode(cfg(), 'claude-p', cwd(), true, noTranscript)
|
|
364
407
|
expect(m.resume).toBe(false)
|
|
365
408
|
expect(m.failReason).toMatch(/nothing to resume|no transcript/)
|
|
366
409
|
})
|
|
367
|
-
|
|
368
|
-
|
|
410
|
+
|
|
411
|
+
// ── branch 3a: default + NOT idle-reaped → it died on its own → FRESH ────────
|
|
412
|
+
test('DEFAULT + NOT idle-reaped (crash/self-close) → FRESH even when a transcript exists', () => {
|
|
413
|
+
// INVERSION of the old polarity: absence of the daemon's idle-reaped marker = died
|
|
414
|
+
// on its own = clean fresh, NOT a resume of a possibly-broken context.
|
|
415
|
+
expect(resolveWakeMode(cfg(), 'claude-p', cwd(), undefined, hasTranscript)).toEqual({ resume: false, cause: 'crash-or-self-close' })
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
// ── branch 3b: default + idle-reaped → resume-eligible, CONSUME the marker ───
|
|
419
|
+
test('DEFAULT + idle-reaped + human-conversational (interfaces.telegram) → RESUME, marker consumed', () => {
|
|
420
|
+
const c = cfg()
|
|
421
|
+
setIdleReaped(c, 'claude-p')
|
|
422
|
+
const human = cwd(true)
|
|
423
|
+
expect(resolveWakeMode(c, 'claude-p', human, undefined, hasTranscript)).toEqual({ resume: true, resumeRef: 'uuid-1', cause: 'idle-reaped-human' })
|
|
424
|
+
expect(hasIdleReaped(c, 'claude-p')).toBe(false) // consumed
|
|
369
425
|
})
|
|
370
|
-
test('
|
|
426
|
+
test('DEFAULT + idle-reaped + executor + NO incoming topic → RESUME (continue the work)', () => {
|
|
371
427
|
const c = cfg()
|
|
372
|
-
|
|
373
|
-
expect(
|
|
374
|
-
|
|
375
|
-
|
|
428
|
+
setIdleReaped(c, 'claude-p')
|
|
429
|
+
expect(resolveWakeMode(c, 'claude-p', cwd(false), undefined, hasTranscript)).toEqual({ resume: true, resumeRef: 'uuid-1', cause: 'idle-reaped-resume' })
|
|
430
|
+
})
|
|
431
|
+
test('DEFAULT + idle-reaped + executor + SAME topic → RESUME', () => {
|
|
432
|
+
const c = cfg()
|
|
433
|
+
setIdleReaped(c, 'claude-p')
|
|
434
|
+
writeTopic(c, 'claude-p', 'deploy')
|
|
435
|
+
expect(resolveWakeMode(c, 'claude-p', cwd(false), undefined, hasTranscript, 'deploy')).toEqual({ resume: true, resumeRef: 'uuid-1', cause: 'idle-reaped-resume' })
|
|
436
|
+
})
|
|
437
|
+
test('DEFAULT + idle-reaped + executor + DIFFERENT topic → FRESH (new work), marker consumed', () => {
|
|
438
|
+
const c = cfg()
|
|
439
|
+
setIdleReaped(c, 'claude-p')
|
|
440
|
+
writeTopic(c, 'claude-p', 'deploy')
|
|
441
|
+
expect(resolveWakeMode(c, 'claude-p', cwd(false), undefined, hasTranscript, 'unrelated-bug')).toEqual({ resume: false, cause: 'idle-reaped-new-topic' })
|
|
442
|
+
expect(hasIdleReaped(c, 'claude-p')).toBe(false) // consumed even on the fresh executor branch
|
|
376
443
|
})
|
|
377
444
|
})
|
|
378
445
|
|
|
379
|
-
describe('
|
|
446
|
+
describe('idle-reaped marker round-trip', () => {
|
|
380
447
|
test('set/has/clear', () => {
|
|
381
|
-
const stateDir = mkdtempSync(join(tmpdir(), 'iapeer-
|
|
448
|
+
const stateDir = mkdtempSync(join(tmpdir(), 'iapeer-idlereap-'))
|
|
382
449
|
const cfg = { stateDir } as LifecycleConfig
|
|
383
450
|
try {
|
|
384
|
-
expect(
|
|
385
|
-
|
|
386
|
-
expect(
|
|
387
|
-
|
|
388
|
-
|
|
451
|
+
expect(hasIdleReaped(cfg, 'claude-y')).toBe(false)
|
|
452
|
+
setIdleReaped(cfg, 'claude-y')
|
|
453
|
+
expect(hasIdleReaped(cfg, 'claude-y')).toBe(true)
|
|
454
|
+
} finally {
|
|
455
|
+
rmSync(stateDir, { recursive: true, force: true })
|
|
456
|
+
}
|
|
457
|
+
})
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
describe('new-eager marker round-trip', () => {
|
|
461
|
+
test('set/has/clear', () => {
|
|
462
|
+
const stateDir = mkdtempSync(join(tmpdir(), 'iapeer-neweager-'))
|
|
463
|
+
const cfg = { stateDir } as LifecycleConfig
|
|
464
|
+
try {
|
|
465
|
+
expect(hasNewEager(cfg, 'claude-y')).toBe(false)
|
|
466
|
+
setNewEager(cfg, 'claude-y')
|
|
467
|
+
expect(hasNewEager(cfg, 'claude-y')).toBe(true)
|
|
468
|
+
clearNewEager(cfg, 'claude-y')
|
|
469
|
+
expect(hasNewEager(cfg, 'claude-y')).toBe(false)
|
|
389
470
|
} finally {
|
|
390
471
|
rmSync(stateDir, { recursive: true, force: true })
|
|
391
472
|
}
|
|
@@ -393,55 +474,54 @@ describe('C4a /new-mark round-trip', () => {
|
|
|
393
474
|
})
|
|
394
475
|
|
|
395
476
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
396
|
-
//
|
|
477
|
+
// superviseTick — death-cause accounting (TARGET redesign)
|
|
478
|
+
// • idle-reap is the ONLY place .idle-reaped is written
|
|
479
|
+
// • a crash/self-close death writes NO marker (lazy fresh on next message)
|
|
480
|
+
// • a dead session carrying .new-eager → needs-eager-fresh (mark LEFT for relaunch)
|
|
481
|
+
// • every dead session records a death (crash-loop accounting)
|
|
397
482
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
398
483
|
|
|
399
|
-
describe('
|
|
400
|
-
|
|
401
|
-
const root = mkdtempSync(join(tmpdir(), 'iapeer-
|
|
402
|
-
const laDir = mkdtempSync(join(tmpdir(), 'iapeer-
|
|
484
|
+
describe('superviseTick death-cause accounting (TARGET)', () => {
|
|
485
|
+
function deadSessionEnv(personality: string): { env: NodeJS.ProcessEnv; cfg: LifecycleConfig; root: string; laDir: string } {
|
|
486
|
+
const root = mkdtempSync(join(tmpdir(), 'iapeer-sup-tgt-root-'))
|
|
487
|
+
const laDir = mkdtempSync(join(tmpdir(), 'iapeer-sup-tgt-la-')) // empty → not launchd-managed
|
|
488
|
+
const env = { ...process.env, IAPEER_ROOT: root, IAPEER_LAUNCHAGENTS_DIR: laDir, IAPEER_SOCK_DIR: join(root, 'socks') }
|
|
489
|
+
const cfg = loadLifecycleConfig(env)
|
|
490
|
+
mkdirSync(cfg.stateDir, { recursive: true })
|
|
491
|
+
writeFileSync(
|
|
492
|
+
join(cfg.stateDir, `claude-${personality}.session`),
|
|
493
|
+
JSON.stringify({ identity: `claude-${personality}`, runtime: 'claude', personality, cwd: `/tmp/${personality}`, wokeAt: Date.now() }),
|
|
494
|
+
)
|
|
495
|
+
return { env, cfg, root, laDir }
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
test('a DEAD session carrying .new-eager → needs-eager-fresh (mark LEFT for relaunch), death recorded', () => {
|
|
499
|
+
const { env, cfg, root, laDir } = deadSessionEnv('z')
|
|
403
500
|
try {
|
|
404
|
-
|
|
405
|
-
...process.env,
|
|
406
|
-
IAPEER_ROOT: root,
|
|
407
|
-
IAPEER_LAUNCHAGENTS_DIR: laDir,
|
|
408
|
-
IAPEER_SOCK_DIR: join(root, 'socks'), // isolated, no live session here → dead
|
|
409
|
-
}
|
|
410
|
-
const cfg = loadLifecycleConfig(env)
|
|
411
|
-
mkdirSync(cfg.stateDir, { recursive: true })
|
|
412
|
-
// a session-state for a peer whose session is NOT live (dead)
|
|
413
|
-
writeFileSync(
|
|
414
|
-
join(cfg.stateDir, 'claude-z.session'),
|
|
415
|
-
JSON.stringify({ identity: 'claude-z', runtime: 'claude', personality: 'z', cwd: '/tmp/z', wokeAt: Date.now() }),
|
|
416
|
-
)
|
|
417
|
-
setNewMark(cfg, 'claude-z')
|
|
501
|
+
setNewEager(cfg, 'claude-z')
|
|
418
502
|
const out = superviseTick(cfg, { env })
|
|
419
503
|
const o = out.find(x => x.identity === 'claude-z')
|
|
420
504
|
expect(o?.action).toBe('needs-eager-fresh')
|
|
421
505
|
expect(o?.personality).toBe('z')
|
|
422
506
|
expect(o?.runtime).toBe('claude')
|
|
423
|
-
// the
|
|
424
|
-
|
|
425
|
-
|
|
507
|
+
// the eager mark is LEFT for processEagerRelaunches to consume
|
|
508
|
+
expect(hasNewEager(cfg, 'claude-z')).toBe(true)
|
|
509
|
+
// every dead session records a death for the crash-loop ring
|
|
510
|
+
expect(readDeaths(cfg, 'claude-z').length).toBe(1)
|
|
426
511
|
} finally {
|
|
427
512
|
rmSync(root, { recursive: true, force: true })
|
|
428
513
|
rmSync(laDir, { recursive: true, force: true })
|
|
429
514
|
}
|
|
430
515
|
})
|
|
431
516
|
|
|
432
|
-
test('a DEAD session with NO
|
|
433
|
-
const root =
|
|
434
|
-
const laDir = mkdtempSync(join(tmpdir(), 'iapeer-c4b2-la-'))
|
|
517
|
+
test('a DEAD session with NO .new-eager → reaped-gone, NO .idle-reaped written (crash leaves no marker)', () => {
|
|
518
|
+
const { env, cfg, root, laDir } = deadSessionEnv('w')
|
|
435
519
|
try {
|
|
436
|
-
const env = { ...process.env, IAPEER_ROOT: root, IAPEER_LAUNCHAGENTS_DIR: laDir, IAPEER_SOCK_DIR: join(root, 'socks') }
|
|
437
|
-
const cfg = loadLifecycleConfig(env)
|
|
438
|
-
mkdirSync(cfg.stateDir, { recursive: true })
|
|
439
|
-
writeFileSync(
|
|
440
|
-
join(cfg.stateDir, 'claude-w.session'),
|
|
441
|
-
JSON.stringify({ identity: 'claude-w', runtime: 'claude', personality: 'w', cwd: '/tmp/w', wokeAt: Date.now() }),
|
|
442
|
-
)
|
|
443
520
|
const out = superviseTick(cfg, { env })
|
|
444
521
|
expect(out.find(x => x.identity === 'claude-w')?.action).toBe('reaped-gone')
|
|
522
|
+
// a crash/self-close is NOT daemon-initiated → no idle-reaped marker → next wake FRESH
|
|
523
|
+
expect(hasIdleReaped(cfg, 'claude-w')).toBe(false)
|
|
524
|
+
expect(readDeaths(cfg, 'claude-w').length).toBe(1)
|
|
445
525
|
} finally {
|
|
446
526
|
rmSync(root, { recursive: true, force: true })
|
|
447
527
|
rmSync(laDir, { recursive: true, force: true })
|
|
@@ -449,6 +529,71 @@ describe('C4b eager fresh re-launch (superviseTick detection)', () => {
|
|
|
449
529
|
})
|
|
450
530
|
})
|
|
451
531
|
|
|
532
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
533
|
+
// Crash-loop guard — wakeOrSpawn refuses to (re)launch after N deaths in the window
|
|
534
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
535
|
+
|
|
536
|
+
describe('crash-loop guard', () => {
|
|
537
|
+
test('countRecentDeaths windows correctly; recordDeath rings', () => {
|
|
538
|
+
const stateDir = mkdtempSync(join(tmpdir(), 'iapeer-deaths-'))
|
|
539
|
+
const cfg = { stateDir } as LifecycleConfig
|
|
540
|
+
try {
|
|
541
|
+
const now = 1_000_000
|
|
542
|
+
recordDeath(cfg, 'claude-d', now - 400_000) // outside a 300s window
|
|
543
|
+
recordDeath(cfg, 'claude-d', now - 10_000)
|
|
544
|
+
recordDeath(cfg, 'claude-d', now - 5_000)
|
|
545
|
+
expect(countRecentDeaths(cfg, 'claude-d', 300, now)).toBe(2)
|
|
546
|
+
expect(countRecentDeaths(cfg, 'claude-d', 600, now)).toBe(3)
|
|
547
|
+
} finally {
|
|
548
|
+
rmSync(stateDir, { recursive: true, force: true })
|
|
549
|
+
}
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
test('wakeOrSpawn REFUSES after crashLoopMax deaths within the window (FAILED, no launch)', async () => {
|
|
553
|
+
const root = mkdtempSync(join(tmpdir(), 'iapeer-clg-root-'))
|
|
554
|
+
const laDir = mkdtempSync(join(tmpdir(), 'iapeer-clg-la-')) // empty → not launchd-managed
|
|
555
|
+
const peerCwd = mkdtempSync(join(tmpdir(), 'iapeer-clg-cwd-')) // REAL cwd so the cwd-existence check passes and the guard is what fires
|
|
556
|
+
try {
|
|
557
|
+
await upsertPeer(
|
|
558
|
+
{ personality: 'clg', runtime: 'claude', cwd: peerCwd, intelligence: 'artificial' },
|
|
559
|
+
{ rootDir: root },
|
|
560
|
+
)
|
|
561
|
+
const env = { ...process.env, IAPEER_ROOT: root, IAPEER_LAUNCHAGENTS_DIR: laDir, IAPEER_SOCK_DIR: join(root, 'socks'), IAPEER_CRASHLOOP_MAX: '3', IAPEER_CRASHLOOP_WINDOW_SECS: '300' }
|
|
562
|
+
const cfg = loadLifecycleConfig(env)
|
|
563
|
+
const now = Date.now()
|
|
564
|
+
recordDeath(cfg, 'claude-clg', now)
|
|
565
|
+
recordDeath(cfg, 'claude-clg', now)
|
|
566
|
+
recordDeath(cfg, 'claude-clg', now)
|
|
567
|
+
const r = await wakeOrSpawn({ personality: 'clg', runtime: 'claude', task: 'must not launch' }, { env })
|
|
568
|
+
expect(r.status).toBe('FAILED')
|
|
569
|
+
expect(r.woke).toBe(false)
|
|
570
|
+
expect(r.reason).toMatch(/crash-loop guard/)
|
|
571
|
+
} finally {
|
|
572
|
+
rmSync(root, { recursive: true, force: true })
|
|
573
|
+
rmSync(laDir, { recursive: true, force: true })
|
|
574
|
+
rmSync(peerCwd, { recursive: true, force: true })
|
|
575
|
+
}
|
|
576
|
+
})
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
580
|
+
// .topic — executor discriminator round-trip
|
|
581
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
582
|
+
|
|
583
|
+
describe('topic marker round-trip', () => {
|
|
584
|
+
test('writeTopic/readTopic', () => {
|
|
585
|
+
const stateDir = mkdtempSync(join(tmpdir(), 'iapeer-topic-'))
|
|
586
|
+
const cfg = { stateDir } as LifecycleConfig
|
|
587
|
+
try {
|
|
588
|
+
expect(readTopic(cfg, 'claude-t')).toBe('')
|
|
589
|
+
writeTopic(cfg, 'claude-t', 'deploy-pipeline')
|
|
590
|
+
expect(readTopic(cfg, 'claude-t')).toBe('deploy-pipeline')
|
|
591
|
+
} finally {
|
|
592
|
+
rmSync(stateDir, { recursive: true, force: true })
|
|
593
|
+
}
|
|
594
|
+
})
|
|
595
|
+
})
|
|
596
|
+
|
|
452
597
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
453
598
|
// Ф-D launch / attach — operator verbs (error paths; success paths are live-verified)
|
|
454
599
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'fs'
|
|
|
3
3
|
import { tmpdir } from 'os'
|
|
4
4
|
import { join } from 'path'
|
|
5
5
|
import {
|
|
6
|
+
clampDescription,
|
|
6
7
|
findPeer,
|
|
7
8
|
readPeersIndex,
|
|
8
9
|
removePeer,
|
|
@@ -10,7 +11,7 @@ import {
|
|
|
10
11
|
withPeersLock,
|
|
11
12
|
type PeerRecord,
|
|
12
13
|
} from './index.ts'
|
|
13
|
-
import { defaultIntelligenceForRuntime, type Intelligence } from '../core/constants.ts'
|
|
14
|
+
import { MAX_DESCRIPTION_LEN, defaultIntelligenceForRuntime, type Intelligence } from '../core/constants.ts'
|
|
14
15
|
import { writeFileAtomic, resolvePeersPaths } from '../storage/index.ts'
|
|
15
16
|
|
|
16
17
|
let root: string
|
|
@@ -398,3 +399,34 @@ describe('Companion fix — withPeersLock fail-closed sandbox isolation', () =>
|
|
|
398
399
|
expect(out).toBe('ok')
|
|
399
400
|
})
|
|
400
401
|
})
|
|
402
|
+
|
|
403
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
404
|
+
// clampDescription boundary — the limit was raised 250 → 450 so self-documenting
|
|
405
|
+
// API-peer descriptions (notifier timer/watcher, ~408 chars) survive intact. The
|
|
406
|
+
// boundary is exact: length == MAX passes untouched, length == MAX+1 truncates.
|
|
407
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
408
|
+
|
|
409
|
+
describe('clampDescription — MAX_DESCRIPTION_LEN boundary (450)', () => {
|
|
410
|
+
test('the limit is 450', () => {
|
|
411
|
+
expect(MAX_DESCRIPTION_LEN).toBe(450)
|
|
412
|
+
})
|
|
413
|
+
test('a 450-char description passes through untouched', () => {
|
|
414
|
+
const at = 'x'.repeat(450)
|
|
415
|
+
const r = clampDescription(at)
|
|
416
|
+
expect(r.truncated).toBe(false)
|
|
417
|
+
expect(r.description).toBe(at)
|
|
418
|
+
expect(r.description.length).toBe(450)
|
|
419
|
+
})
|
|
420
|
+
test('a 451-char description is truncated to 450', () => {
|
|
421
|
+
const over = 'y'.repeat(451)
|
|
422
|
+
const r = clampDescription(over)
|
|
423
|
+
expect(r.truncated).toBe(true)
|
|
424
|
+
expect(r.description.length).toBe(450)
|
|
425
|
+
expect(r.description).toBe('y'.repeat(450))
|
|
426
|
+
})
|
|
427
|
+
test('upsertPeer persists a full 450-char description (no clamp at the boundary)', async () => {
|
|
428
|
+
const desc = 'z'.repeat(450)
|
|
429
|
+
await upsertPeer({ personality: 'verbose', runtime: 'claude', cwd: '/tmp/verbose', description: desc }, opts())
|
|
430
|
+
expect(findPeer(readPeersIndex(opts()), 'verbose')!.description).toBe(desc)
|
|
431
|
+
})
|
|
432
|
+
})
|