@agfpd/iapeer 0.2.26 → 0.2.28
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 +1 -1
- package/src/cli/index.ts +57 -1
- package/src/enable/memoryPlugin.test.ts +94 -0
- package/src/enable/memoryPlugin.ts +78 -1
- package/src/enable/provisionCommand.test.ts +107 -0
- package/src/enable/provisionCommand.ts +112 -0
- package/src/launch/canary.test.ts +93 -5
- package/src/launch/canary.ts +76 -16
- package/src/launch/index.ts +30 -7
- package/src/launch/launch.test.ts +8 -0
- package/src/launch/launchdRun.ts +4 -1
- package/src/lifecycle/index.ts +14 -4
- package/src/provision/index.ts +51 -5
- package/src/provision/provision.test.ts +63 -0
- package/src/status/index.ts +39 -1
- package/src/status/status.test.ts +27 -0
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -48,6 +48,9 @@ import { resolveCallerIdentity, resolveIdentity } from '../identity/index.ts'
|
|
|
48
48
|
import { runAlwaysOn } from '../launch/launchdRun.ts'
|
|
49
49
|
import { installDaemonPlist, startConfiguredDaemon } from '../daemon/main.ts'
|
|
50
50
|
import { MARKETPLACE_NAME, onboardHost } from '../onboard/index.ts'
|
|
51
|
+
import { readMemoryProvider } from '../status/index.ts'
|
|
52
|
+
import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
|
|
53
|
+
import { pluginLogsDir } from '../storage/index.ts'
|
|
51
54
|
|
|
52
55
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
53
56
|
// list — registry + per-runtime liveness (contract Примитивы §list)
|
|
@@ -274,6 +277,9 @@ export interface RemoveOutcome {
|
|
|
274
277
|
* deliberately keeps the folder — user data is never deleted by a registry reap
|
|
275
278
|
* (boris's finding 10.06: say so in the output instead of leaving silent orphans). */
|
|
276
279
|
cwd?: string
|
|
280
|
+
/** v1.2: per-runtime unprovision outcomes (`<rt>:<state>`), present when the
|
|
281
|
+
* slot declares an unprovision command (occasion=remove ran before the purge). */
|
|
282
|
+
unprovision?: string[]
|
|
277
283
|
/** Identity-keyed lifecycle artifacts purged with the record (state/lifecycle/
|
|
278
284
|
* `<identity>.*` per runtime). Without this purge a NEWBORN peer reusing the
|
|
279
285
|
* personality inherits the dead namesake's parking (live defect, boris 10.06:
|
|
@@ -309,13 +315,59 @@ export async function removePeerCli(
|
|
|
309
315
|
}
|
|
310
316
|
}
|
|
311
317
|
await removePeer(personality, { env })
|
|
318
|
+
// v1.2 UNPROVISION joint (контракт §Provision провайдера): a provision-declaring
|
|
319
|
+
// slot gets its unprovision command per agentic runtime with occasion=remove —
|
|
320
|
+
// BEFORE purgeIdentityState, so the provider sees the peer's last consistent
|
|
321
|
+
// state while unwinding its surfaces. Best-effort: a provider hiccup must not
|
|
322
|
+
// block the reap (the outcome line says what happened; repair is the provider's
|
|
323
|
+
// verify sweep).
|
|
324
|
+
const unprovisionOutcomes: string[] = []
|
|
325
|
+
try {
|
|
326
|
+
const slot = readMemoryProvider(env)
|
|
327
|
+
if (slot?.unprovision) {
|
|
328
|
+
const { runProvisionCommand } = await import('../enable/provisionCommand.ts')
|
|
329
|
+
const agentic = peer.runtimes.filter((r): r is 'claude' | 'codex' => r === 'claude' || r === 'codex')
|
|
330
|
+
for (const rt of agentic) {
|
|
331
|
+
const o = runProvisionCommand({
|
|
332
|
+
block: slot.unprovision,
|
|
333
|
+
cwd: peer.cwd,
|
|
334
|
+
runtime: rt,
|
|
335
|
+
personality,
|
|
336
|
+
occasion: 'remove',
|
|
337
|
+
env,
|
|
338
|
+
})
|
|
339
|
+
appendLifecycleEvent(
|
|
340
|
+
pluginLogsDir('iapeer', { env }),
|
|
341
|
+
{
|
|
342
|
+
ev: 'memory-provision',
|
|
343
|
+
identity: `${rt}-${personality}`,
|
|
344
|
+
occasion: 'remove',
|
|
345
|
+
state: o.state,
|
|
346
|
+
exit: o.exitCode ?? undefined,
|
|
347
|
+
ms: o.durationMs,
|
|
348
|
+
detail: o.detail,
|
|
349
|
+
},
|
|
350
|
+
{ env },
|
|
351
|
+
)
|
|
352
|
+
unprovisionOutcomes.push(`${rt}:${o.state}${o.state !== 'ok' && o.detail ? ` (${o.detail})` : ''}`)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
} catch (e) {
|
|
356
|
+
unprovisionOutcomes.push(`failed: ${e instanceof Error ? e.message : String(e)}`)
|
|
357
|
+
}
|
|
312
358
|
// Purge identity-keyed lifecycle state WITH the record (per runtime): stale
|
|
313
359
|
// .stopped/.idle-reaped/... must never outlive the peer and ambush a future
|
|
314
360
|
// namesake (purgeIdentityState doc). After the registry write, so a failed
|
|
315
361
|
// remove never half-purges a still-registered peer.
|
|
316
362
|
const cfg = loadLifecycleConfig(env)
|
|
317
363
|
const purgedState = peer.runtimes.flatMap(rt => purgeIdentityState(cfg, buildProcessAddress(rt, personality)))
|
|
318
|
-
return {
|
|
364
|
+
return {
|
|
365
|
+
personality,
|
|
366
|
+
action: 'removed',
|
|
367
|
+
cwd: peer.cwd,
|
|
368
|
+
purgedState,
|
|
369
|
+
...(unprovisionOutcomes.length ? { unprovision: unprovisionOutcomes } : {}),
|
|
370
|
+
}
|
|
319
371
|
}
|
|
320
372
|
|
|
321
373
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -764,6 +816,10 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
764
816
|
const o = await removePeerCli(positionals[0], { force: flags.force === true, env })
|
|
765
817
|
if (o.action === 'removed') {
|
|
766
818
|
out(`removed "${o.personality}" from the registry\n`)
|
|
819
|
+
// v1.2: the provider unwound its surfaces (occasion=remove) — say how it went.
|
|
820
|
+
if (o.unprovision?.length) {
|
|
821
|
+
out(`memory unprovision: ${o.unprovision.join(', ')}\n`)
|
|
822
|
+
}
|
|
767
823
|
// Stale identity-keyed markers must die with the record (boris 10.06: a
|
|
768
824
|
// namesake newborn inherited a dead peer's .stopped → refused to wake).
|
|
769
825
|
if (o.purgedState?.length) {
|
|
@@ -101,3 +101,97 @@ describe('memory-plugin gating (slot-derived)', () => {
|
|
|
101
101
|
expect(codexOutcomes.filter(x => x.state === 'already').length).toBe(1)
|
|
102
102
|
})
|
|
103
103
|
})
|
|
104
|
+
|
|
105
|
+
// ─── v1.2 provision-инверсия (контракт §Provision провайдера, ADR-009) ───────
|
|
106
|
+
|
|
107
|
+
import { chmodSync, readFileSync, existsSync } from 'fs'
|
|
108
|
+
|
|
109
|
+
/** Declare a v1.2 slot whose provision/unprovision are fake journaling scripts. */
|
|
110
|
+
function declareProvisionSlot(o: { provision?: boolean; unprovision?: boolean; alsoPlugin?: boolean; failing?: boolean }): {
|
|
111
|
+
journal: string
|
|
112
|
+
} {
|
|
113
|
+
const journal = join(root, 'journal.txt')
|
|
114
|
+
const script = join(root, 'fake-provider.sh')
|
|
115
|
+
writeFileSync(
|
|
116
|
+
script,
|
|
117
|
+
o.failing ? `#!/bin/sh\necho provider broken >&2\nexit 7\n` : `#!/bin/sh\nprintf '%s\\n' "$@" >> '${journal}'\n`,
|
|
118
|
+
)
|
|
119
|
+
chmodSync(script, 0o755)
|
|
120
|
+
const block = (occ: string) => ({
|
|
121
|
+
command: script,
|
|
122
|
+
args: [occ, '--cwd', '{cwd}', '--runtime', '{runtime}', '--personality', '{personality}', '--occasion', '{occasion}'],
|
|
123
|
+
})
|
|
124
|
+
writeFileSync(
|
|
125
|
+
memoryProviderPath(env()),
|
|
126
|
+
JSON.stringify({
|
|
127
|
+
...DECLARATION,
|
|
128
|
+
...(o.alsoPlugin ? { plugin: PLUGIN } : {}),
|
|
129
|
+
...(o.provision !== false ? { provision: block('provision-peer') } : {}),
|
|
130
|
+
...(o.unprovision !== false ? { unprovision: block('unprovision-peer') } : {}),
|
|
131
|
+
}),
|
|
132
|
+
)
|
|
133
|
+
return { journal }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
describe('memory-plugin v1.2 provision routing', () => {
|
|
137
|
+
test('on --peer: provision command invoked per agentic runtime with occasion=sweep-on, substituted argv', async () => {
|
|
138
|
+
const { journal } = declareProvisionSlot({})
|
|
139
|
+
await upsertPeer({ personality: 'alpha', runtime: 'claude', cwd: join(root, 'alpha'), intelligence: 'artificial' }, { rootDir: root })
|
|
140
|
+
const r = memoryPluginApply('on', { peer: 'alpha', env: env() })
|
|
141
|
+
expect(r.ok).toBe(true)
|
|
142
|
+
expect(r.outcomes).toEqual([{ personality: 'alpha', runtime: 'claude', state: 'ok', detail: undefined }])
|
|
143
|
+
const j = readFileSync(journal, 'utf8').split('\n')
|
|
144
|
+
expect(j[0]).toBe('provision-peer')
|
|
145
|
+
expect(j.slice(1, 9)).toEqual(['--cwd', join(root, 'alpha'), '--runtime', 'claude', '--personality', 'alpha', '--occasion', 'sweep-on'])
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('PRECEDENCE: slot with BOTH blocks routes to provision; the plugin path is never touched', async () => {
|
|
149
|
+
const { journal } = declareProvisionSlot({ alsoPlugin: true })
|
|
150
|
+
await upsertPeer({ personality: 'beta', runtime: 'claude', cwd: join(root, 'beta'), intelligence: 'artificial' }, { rootDir: root })
|
|
151
|
+
const r = memoryPluginApply('on', { peer: 'beta', env: env() })
|
|
152
|
+
expect(r.ok).toBe(true)
|
|
153
|
+
// plugin path on this sandbox would yield 'runtime-missing' (bins at /nonexistent);
|
|
154
|
+
// 'ok' from the journaling script proves the provision command ran instead.
|
|
155
|
+
expect(r.outcomes[0]?.state).toBe('ok')
|
|
156
|
+
expect(readFileSync(journal, 'utf8')).toContain('provision-peer')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('provision failure → ok=false with detail, NO fallback to the plugin path', async () => {
|
|
160
|
+
declareProvisionSlot({ failing: true, alsoPlugin: true })
|
|
161
|
+
await upsertPeer({ personality: 'gamma', runtime: 'claude', cwd: join(root, 'gamma'), intelligence: 'artificial' }, { rootDir: root })
|
|
162
|
+
const r = memoryPluginApply('on', { peer: 'gamma', env: env() })
|
|
163
|
+
expect(r.ok).toBe(false)
|
|
164
|
+
expect(r.outcomes[0]?.state).toBe('failed')
|
|
165
|
+
expect(r.outcomes[0]?.detail).toContain('provider broken')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test('off --peer → unprovision occasion=off-peer; off --all → off-all; codex has NO host-global special-casing', async () => {
|
|
169
|
+
const { journal } = declareProvisionSlot({})
|
|
170
|
+
await upsertPeer({ personality: 'cx', runtime: 'codex', cwd: join(root, 'cx'), intelligence: 'artificial' }, { rootDir: root })
|
|
171
|
+
const r1 = memoryPluginApply('off', { peer: 'cx', env: env() })
|
|
172
|
+
expect(r1.ok).toBe(true)
|
|
173
|
+
expect(r1.outcomes[0]).toEqual({ personality: 'cx', runtime: 'codex', state: 'ok', detail: undefined })
|
|
174
|
+
const r2 = memoryPluginApply('off', { all: true, env: env() })
|
|
175
|
+
expect(r2.ok).toBe(true)
|
|
176
|
+
const j = readFileSync(journal, 'utf8')
|
|
177
|
+
expect(j).toContain('off-peer')
|
|
178
|
+
expect(j).toContain('off-all')
|
|
179
|
+
expect(j).toContain('unprovision-peer')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('missing direction block → explicit refusal (no silent skip, no plugin fallback)', async () => {
|
|
183
|
+
declareProvisionSlot({ unprovision: false, alsoPlugin: true })
|
|
184
|
+
await upsertPeer({ personality: 'delta', runtime: 'claude', cwd: join(root, 'delta'), intelligence: 'artificial' }, { rootDir: root })
|
|
185
|
+
const r = memoryPluginApply('off', { peer: 'delta', env: env() })
|
|
186
|
+
expect(r.ok).toBe(false)
|
|
187
|
+
expect(r.error).toContain('declares no unprovision command')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('non-agentic peer → explicit skip (provision never invoked)', async () => {
|
|
191
|
+
const { journal } = declareProvisionSlot({})
|
|
192
|
+
await upsertPeer({ personality: 'tim2', runtime: 'notifier', cwd: join(root, 'tim2'), intelligence: 'absent' }, { rootDir: root })
|
|
193
|
+
const r = memoryPluginApply('on', { peer: 'tim2', env: env() })
|
|
194
|
+
expect(r.outcomes[0]?.state).toBe('skipped-no-agentic-runtime')
|
|
195
|
+
expect(existsSync(journal)).toBe(false)
|
|
196
|
+
})
|
|
197
|
+
})
|
|
@@ -12,14 +12,17 @@
|
|
|
12
12
|
// the declaration — agreed AUTO-removal: a dead provider's plugin actively
|
|
13
13
|
// errors in every session, unlike the native-memory restore asymmetry).
|
|
14
14
|
|
|
15
|
+
import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
|
|
15
16
|
import { findPeer, readPeersIndex } from '../registry/index.ts'
|
|
16
|
-
import { readMemoryProvider, type MemoryProviderPlugin } from '../status/index.ts'
|
|
17
|
+
import { readMemoryProvider, type MemoryProviderPlugin, type MemoryProviderProvision } from '../status/index.ts'
|
|
18
|
+
import { pluginLogsDir } from '../storage/index.ts'
|
|
17
19
|
import {
|
|
18
20
|
enableCapabilityForCwd,
|
|
19
21
|
removeClaudeForCwd,
|
|
20
22
|
removeCodexGlobal,
|
|
21
23
|
type CapabilityRuntime,
|
|
22
24
|
} from './index.ts'
|
|
25
|
+
import { runProvisionCommand, type ProvisionOccasion } from './provisionCommand.ts'
|
|
23
26
|
|
|
24
27
|
export interface MemoryPluginOutcome {
|
|
25
28
|
personality: string
|
|
@@ -58,6 +61,13 @@ export function memoryPluginApply(state: 'on' | 'off', opts: MemoryPluginOptions
|
|
|
58
61
|
if (!slot) {
|
|
59
62
|
return { ok: false, plugin: null, error: 'memory slot is EMPTY — no provider declared, nothing to install', outcomes: [] }
|
|
60
63
|
}
|
|
64
|
+
// v1.2 PRECEDENCE (контракт §Provision провайдера): a provision-declaring slot
|
|
65
|
+
// routes BOTH verb directions through the provider's commands; the deprecated
|
|
66
|
+
// v1.1 plugin block is ignored entirely (no fallback — masking a provision
|
|
67
|
+
// failure would hide a broken provider behind a stale plugin install).
|
|
68
|
+
if (slot.provision || slot.unprovision) {
|
|
69
|
+
return memoryProvisionApply(state, slot.provision, slot.unprovision, slot.provider, opts, env)
|
|
70
|
+
}
|
|
61
71
|
if (!slot.plugin) {
|
|
62
72
|
return {
|
|
63
73
|
ok: false,
|
|
@@ -131,3 +141,70 @@ export function memoryPluginApply(state: 'on' | 'off', opts: MemoryPluginOptions
|
|
|
131
141
|
const failed = outcomes.some(o => o.state === 'failed' || o.state === 'setup-failed')
|
|
132
142
|
return { ok: !failed, plugin, outcomes }
|
|
133
143
|
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* v1.2 verb routing through the provider's provision/unprovision commands
|
|
147
|
+
* (occasion: on → sweep-on; off --peer → off-peer; off --all → off-all). One
|
|
148
|
+
* invocation per target peer × agentic runtime — NO host-global special-casing
|
|
149
|
+
* here: ref-counting of host-global surfaces (codex) moved to the PROVIDER with
|
|
150
|
+
* the inversion (contract requirement 4). A direction whose command block is
|
|
151
|
+
* absent is an explicit refusal (never silently skipped, never plugin-fallback).
|
|
152
|
+
*/
|
|
153
|
+
function memoryProvisionApply(
|
|
154
|
+
state: 'on' | 'off',
|
|
155
|
+
provision: MemoryProviderProvision | undefined,
|
|
156
|
+
unprovision: MemoryProviderProvision | undefined,
|
|
157
|
+
provider: string,
|
|
158
|
+
opts: MemoryPluginOptions,
|
|
159
|
+
env: NodeJS.ProcessEnv,
|
|
160
|
+
): MemoryPluginResult {
|
|
161
|
+
const block = state === 'on' ? provision : unprovision
|
|
162
|
+
if (!block) {
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
plugin: null,
|
|
166
|
+
error:
|
|
167
|
+
`provider "${provider}" declares no ${state === 'on' ? 'provision' : 'unprovision'} command ` +
|
|
168
|
+
`(declaration v1.2 requires both directions) — fix the provider's declaration`,
|
|
169
|
+
outcomes: [],
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const index = readPeersIndex({ env })
|
|
173
|
+
const targets = opts.all === true ? index.peers : opts.peer ? (p => (p ? [p] : []))(findPeer(index, opts.peer)) : []
|
|
174
|
+
if (targets.length === 0) {
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
plugin: null,
|
|
178
|
+
error: opts.peer ? `peer "${opts.peer}" is not in the iapeer peers index` : 'no targets — pass --peer <p> or --all',
|
|
179
|
+
outcomes: [],
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const occasion: ProvisionOccasion = state === 'on' ? 'sweep-on' : opts.all === true ? 'off-all' : 'off-peer'
|
|
183
|
+
const outcomes: MemoryPluginOutcome[] = []
|
|
184
|
+
for (const p of targets) {
|
|
185
|
+
const agentic = p.runtimes.filter((r): r is CapabilityRuntime => r === 'claude' || r === 'codex')
|
|
186
|
+
if (agentic.length === 0) {
|
|
187
|
+
outcomes.push({ personality: p.personality, runtime: 'none', state: 'skipped-no-agentic-runtime' })
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
for (const rt of agentic) {
|
|
191
|
+
const o = runProvisionCommand({ block, cwd: p.cwd, runtime: rt, personality: p.personality, occasion, env })
|
|
192
|
+
appendLifecycleEvent(
|
|
193
|
+
pluginLogsDir('iapeer', { env }),
|
|
194
|
+
{
|
|
195
|
+
ev: 'memory-provision',
|
|
196
|
+
identity: `${rt}-${p.personality}`,
|
|
197
|
+
occasion,
|
|
198
|
+
state: o.state,
|
|
199
|
+
exit: o.exitCode ?? undefined,
|
|
200
|
+
ms: o.durationMs,
|
|
201
|
+
detail: o.detail,
|
|
202
|
+
},
|
|
203
|
+
{ env },
|
|
204
|
+
)
|
|
205
|
+
outcomes.push({ personality: p.personality, runtime: rt, state: o.state, detail: o.detail })
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const failed = outcomes.some(o => o.state !== 'ok' && o.state !== 'skipped-no-agentic-runtime')
|
|
209
|
+
return { ok: !failed, plugin: null, outcomes }
|
|
210
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Provision-command executor — the v1.2 inversion joint. Pins the four contract
|
|
2
|
+
// requirements: per-arg placeholder substitution WITHOUT a shell (injection
|
|
3
|
+
// class), absolute command, timeout, structured outcomes. The fake provider is
|
|
4
|
+
// a tmp shell script that journals its argv — no real provider involved.
|
|
5
|
+
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
7
|
+
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
8
|
+
import { tmpdir } from 'os'
|
|
9
|
+
import { join } from 'path'
|
|
10
|
+
import { runProvisionCommand } from './provisionCommand.ts'
|
|
11
|
+
|
|
12
|
+
let dir: string
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
dir = mkdtempSync(join(tmpdir(), 'iapeer-provcmd-'))
|
|
15
|
+
})
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
rmSync(dir, { recursive: true, force: true })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
/** A fake provider command journaling each argv element on its own line. */
|
|
21
|
+
function fakeProvider(name: string, body?: string): string {
|
|
22
|
+
const p = join(dir, name)
|
|
23
|
+
writeFileSync(p, `#!/bin/sh\n${body ?? `printf '%s\\n' "$@" > '${dir}/journal.txt'`}\n`)
|
|
24
|
+
chmodSync(p, 0o755)
|
|
25
|
+
return p
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('runProvisionCommand (v1.2 executor)', () => {
|
|
29
|
+
test('per-arg substitution of {cwd}/{runtime}/{personality}/{occasion}, argv passed verbatim (no shell)', () => {
|
|
30
|
+
const cmd = fakeProvider('prov.sh')
|
|
31
|
+
const o = runProvisionCommand({
|
|
32
|
+
block: {
|
|
33
|
+
command: cmd,
|
|
34
|
+
// both shapes: bare placeholder arg and embedded `--k={v}`; plus an arg
|
|
35
|
+
// full of shell metacharacters that MUST stay literal (no shell layer)
|
|
36
|
+
args: ['provision-peer', '--cwd', '{cwd}', '--runtime={runtime}', '--occasion', '{occasion}', '$(reboot) `id` ; rm -rf /'],
|
|
37
|
+
},
|
|
38
|
+
cwd: '/tmp/some peer dir',
|
|
39
|
+
runtime: 'claude',
|
|
40
|
+
personality: 'tw-prov',
|
|
41
|
+
occasion: 'birth',
|
|
42
|
+
})
|
|
43
|
+
expect(o.state).toBe('ok')
|
|
44
|
+
expect(o.exitCode).toBe(0)
|
|
45
|
+
const journal = readFileSync(join(dir, 'journal.txt'), 'utf8').split('\n')
|
|
46
|
+
expect(journal[0]).toBe('provision-peer')
|
|
47
|
+
expect(journal[1]).toBe('--cwd')
|
|
48
|
+
expect(journal[2]).toBe('/tmp/some peer dir') // spaces survive — single argv element
|
|
49
|
+
expect(journal[3]).toBe('--runtime=claude') // embedded substitution
|
|
50
|
+
expect(journal[4]).toBe('--occasion')
|
|
51
|
+
expect(journal[5]).toBe('birth')
|
|
52
|
+
expect(journal[6]).toBe('$(reboot) `id` ; rm -rf /') // LITERAL — never shell-parsed
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('{personality} placeholder is supported (optional for the provider)', () => {
|
|
56
|
+
const cmd = fakeProvider('prov.sh')
|
|
57
|
+
const o = runProvisionCommand({
|
|
58
|
+
block: { command: cmd, args: ['{personality}'] },
|
|
59
|
+
cwd: '/x',
|
|
60
|
+
runtime: 'codex',
|
|
61
|
+
personality: 'boris',
|
|
62
|
+
occasion: 'sweep-on',
|
|
63
|
+
})
|
|
64
|
+
expect(o.state).toBe('ok')
|
|
65
|
+
expect(readFileSync(join(dir, 'journal.txt'), 'utf8').trim()).toBe('boris')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('non-zero exit → failed with exit code and stderr tail', () => {
|
|
69
|
+
const cmd = fakeProvider('prov.sh', `echo 'no such peer' >&2; exit 3`)
|
|
70
|
+
const o = runProvisionCommand({
|
|
71
|
+
block: { command: cmd, args: [] },
|
|
72
|
+
cwd: '/x',
|
|
73
|
+
runtime: 'claude',
|
|
74
|
+
personality: 'p',
|
|
75
|
+
occasion: 'off-peer',
|
|
76
|
+
})
|
|
77
|
+
expect(o.state).toBe('failed')
|
|
78
|
+
expect(o.exitCode).toBe(3)
|
|
79
|
+
expect(o.detail).toContain('no such peer')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('timeout → state=timeout, never hangs the joint', () => {
|
|
83
|
+
const cmd = fakeProvider('prov.sh', 'sleep 30')
|
|
84
|
+
const o = runProvisionCommand({
|
|
85
|
+
block: { command: cmd, args: [] },
|
|
86
|
+
cwd: '/x',
|
|
87
|
+
runtime: 'claude',
|
|
88
|
+
personality: 'p',
|
|
89
|
+
occasion: 'remove',
|
|
90
|
+
timeoutMs: 300,
|
|
91
|
+
})
|
|
92
|
+
expect(o.state).toBe('timeout')
|
|
93
|
+
expect(o.durationMs).toBeLessThan(5000)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('missing/non-executable command → not-executable (never throws)', () => {
|
|
97
|
+
const o = runProvisionCommand({
|
|
98
|
+
block: { command: join(dir, 'nope.sh'), args: [] },
|
|
99
|
+
cwd: '/x',
|
|
100
|
+
runtime: 'claude',
|
|
101
|
+
personality: 'p',
|
|
102
|
+
occasion: 'birth',
|
|
103
|
+
})
|
|
104
|
+
expect(o.state).toBe('not-executable')
|
|
105
|
+
expect(o.exitCode).toBeNull()
|
|
106
|
+
})
|
|
107
|
+
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Provision-command executor — the v1.2 inversion joint (контракт «Слот памяти»
|
|
2
|
+
// §Provision провайдера; ADR-009, решение Артура 10.06, схема сверена с
|
|
3
|
+
// iapeer-memory 11.06). The slot declares provision/unprovision COMMANDS; the
|
|
4
|
+
// core shells into them at the lifecycle joints and knows NOTHING about surface
|
|
5
|
+
// forms. The four contract requirements live here:
|
|
6
|
+
// 1. argv-form {command, args[]} with PER-ARG placeholder substitution —
|
|
7
|
+
// spawned WITHOUT a shell (injection/quoting class; бэктик-грабли 10.06).
|
|
8
|
+
// 2. `command` is ABSOLUTE (parser refuses relative — launchd minimal PATH).
|
|
9
|
+
// 3. Timeout 120 s; best-effort semantics live at the CALL SITES (LOUD warn,
|
|
10
|
+
// the birth/remove flow continues) — this module only reports structurally.
|
|
11
|
+
// 4. {occasion} vocabulary: birth | sweep-on | off-peer | off-all | remove —
|
|
12
|
+
// ref-counting of host-global surfaces is the PROVIDER's business.
|
|
13
|
+
// Idempotency is the provider's obligation by construction; the provider holds
|
|
14
|
+
// its own lock against concurrent provisions.
|
|
15
|
+
|
|
16
|
+
import { spawnSync } from 'child_process'
|
|
17
|
+
import { accessSync, constants as FS } from 'fs'
|
|
18
|
+
import type { MemoryProviderProvision } from '../status/index.ts'
|
|
19
|
+
|
|
20
|
+
/** The agreed occasion vocabulary (финален, сверено с iapeer-memory). */
|
|
21
|
+
export type ProvisionOccasion = 'birth' | 'sweep-on' | 'off-peer' | 'off-all' | 'remove'
|
|
22
|
+
|
|
23
|
+
export const PROVISION_TIMEOUT_MS = 120_000
|
|
24
|
+
|
|
25
|
+
export interface ProvisionCommandSpec {
|
|
26
|
+
/** The declared command block (provision or unprovision). */
|
|
27
|
+
block: MemoryProviderProvision
|
|
28
|
+
/** Placeholder values — substituted PER-ARG, substring-level (so both
|
|
29
|
+
* `--cwd {cwd}` and `--cwd={cwd}` argv shapes work). */
|
|
30
|
+
cwd: string
|
|
31
|
+
runtime: string
|
|
32
|
+
personality: string
|
|
33
|
+
occasion: ProvisionOccasion
|
|
34
|
+
env?: NodeJS.ProcessEnv
|
|
35
|
+
timeoutMs?: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ProvisionCommandOutcome {
|
|
39
|
+
state: 'ok' | 'failed' | 'timeout' | 'not-executable'
|
|
40
|
+
exitCode: number | null
|
|
41
|
+
/** stderr tail (last ~400 chars) — the structured detail for eventlog/warns. */
|
|
42
|
+
detail?: string
|
|
43
|
+
durationMs: number
|
|
44
|
+
/** The fully substituted argv (command first) — logged for postmortems. */
|
|
45
|
+
argv: string[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function substitute(arg: string, s: ProvisionCommandSpec): string {
|
|
49
|
+
return arg
|
|
50
|
+
.replaceAll('{cwd}', s.cwd)
|
|
51
|
+
.replaceAll('{runtime}', s.runtime)
|
|
52
|
+
.replaceAll('{personality}', s.personality)
|
|
53
|
+
.replaceAll('{occasion}', s.occasion)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function tail(text: string, max = 400): string | undefined {
|
|
57
|
+
const t = text.trim()
|
|
58
|
+
if (!t) return undefined
|
|
59
|
+
return t.length <= max ? t : `…${t.slice(-max)}`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Run ONE provider provision/unprovision command for ONE peer × runtime ×
|
|
64
|
+
* occasion. Synchronous (the call sites are the synchronous provision/remove
|
|
65
|
+
* flows), no shell, bounded by timeout. NEVER throws — every failure is a
|
|
66
|
+
* structured outcome the call site warns about (best-effort: a provider hiccup
|
|
67
|
+
* must not kill a birth or a remove).
|
|
68
|
+
*/
|
|
69
|
+
export function runProvisionCommand(spec: ProvisionCommandSpec): ProvisionCommandOutcome {
|
|
70
|
+
const env = spec.env ?? process.env
|
|
71
|
+
const argv = [spec.block.command, ...spec.block.args.map(a => substitute(a, spec))]
|
|
72
|
+
const started = Date.now()
|
|
73
|
+
try {
|
|
74
|
+
accessSync(spec.block.command, FS.X_OK)
|
|
75
|
+
} catch {
|
|
76
|
+
return {
|
|
77
|
+
state: 'not-executable',
|
|
78
|
+
exitCode: null,
|
|
79
|
+
detail: `command not found or not executable: ${spec.block.command}`,
|
|
80
|
+
durationMs: Date.now() - started,
|
|
81
|
+
argv,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const r = spawnSync(argv[0]!, argv.slice(1), {
|
|
86
|
+
env: env as Record<string, string>,
|
|
87
|
+
encoding: 'utf8',
|
|
88
|
+
timeout: spec.timeoutMs ?? PROVISION_TIMEOUT_MS,
|
|
89
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
90
|
+
})
|
|
91
|
+
const durationMs = Date.now() - started
|
|
92
|
+
// node/bun signal a timeout via error.code ETIMEDOUT + signal SIGTERM
|
|
93
|
+
if (r.error && (r.error as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
|
|
94
|
+
return { state: 'timeout', exitCode: null, detail: `timed out after ${spec.timeoutMs ?? PROVISION_TIMEOUT_MS} ms`, durationMs, argv }
|
|
95
|
+
}
|
|
96
|
+
if (r.error) {
|
|
97
|
+
return { state: 'failed', exitCode: null, detail: r.error.message, durationMs, argv }
|
|
98
|
+
}
|
|
99
|
+
if (r.status !== 0) {
|
|
100
|
+
return { state: 'failed', exitCode: r.status, detail: tail(r.stderr ?? ''), durationMs, argv }
|
|
101
|
+
}
|
|
102
|
+
return { state: 'ok', exitCode: 0, detail: tail(r.stderr ?? ''), durationMs, argv }
|
|
103
|
+
} catch (e) {
|
|
104
|
+
return {
|
|
105
|
+
state: 'failed',
|
|
106
|
+
exitCode: null,
|
|
107
|
+
detail: e instanceof Error ? e.message : String(e),
|
|
108
|
+
durationMs: Date.now() - started,
|
|
109
|
+
argv,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -10,7 +10,9 @@ import { tmpdir } from 'os'
|
|
|
10
10
|
import { join } from 'path'
|
|
11
11
|
import {
|
|
12
12
|
canaryChannel,
|
|
13
|
+
canaryProcessPattern,
|
|
13
14
|
canaryScript,
|
|
15
|
+
dismissCanary,
|
|
14
16
|
ensureServerCanary,
|
|
15
17
|
exitLogPath,
|
|
16
18
|
serverDeathsDir,
|
|
@@ -53,7 +55,7 @@ describe('canary script (pure)', () => {
|
|
|
53
55
|
expect(exitLogPath('/r/logs/iapeer')).toBe('/r/logs/iapeer/exits.log')
|
|
54
56
|
})
|
|
55
57
|
|
|
56
|
-
test('script carries the protocol: wait-for channel,
|
|
58
|
+
test('script carries the v2 protocol: wait-for channel, deliberate-silence guards, liveness probe, record line, forensics', () => {
|
|
57
59
|
const s = canaryScript({
|
|
58
60
|
identity: 'claude-bob',
|
|
59
61
|
sock: '/tmp/x.sock',
|
|
@@ -62,8 +64,15 @@ describe('canary script (pure)', () => {
|
|
|
62
64
|
forensicsDir: '/r/logs/iapeer/server-deaths',
|
|
63
65
|
})
|
|
64
66
|
expect(s).toContain(`wait-for 'iap-canary-claude-bob'`) // the blocking client
|
|
65
|
-
expect(s).toContain(`trap 'exit 0' HUP INT TERM`) //
|
|
66
|
-
|
|
67
|
+
expect(s).toContain(`trap 'exit 0' HUP INT TERM`) // dismissed sh = silent (the ONLY deliberate silencer)
|
|
68
|
+
// v2: NO exit code is trusted as deliberate (a TERMed client returns rc=0 —
|
|
69
|
+
// proven live); the SERVER's liveness decides, after a dismissal grace sleep.
|
|
70
|
+
expect(s).not.toContain(`[ "$rc" -eq 0 ] || [ "$rc" -ge 128 ]; then exit 0`) // the v1 hole
|
|
71
|
+
expect(s).toContain('sleep 2') // dismissal grace window
|
|
72
|
+
expect(s).toContain(`has-session 2>/dev/null; then exit 0`) // server alive → nothing to record
|
|
73
|
+
expect(s).toContain('cause=server-vanished') // connection drop (SIGKILL/OOM class)
|
|
74
|
+
expect(s).toContain('cause=signaled-server-gone') // rc=0: channel/client-TERM, server died
|
|
75
|
+
expect(s).toContain('cause=client-killed-server-gone') // rc≥128: client took a hard kill
|
|
67
76
|
expect(s).toContain('ev=server-exit identity=claude-bob') // the exits.log record
|
|
68
77
|
expect(s).toContain(`>> '/r/logs/iapeer/exits.log'`)
|
|
69
78
|
expect(s).toContain('/r/logs/iapeer/server-deaths/claude-bob') // forensics file
|
|
@@ -74,6 +83,19 @@ describe('canary script (pure)', () => {
|
|
|
74
83
|
test('ensureServerCanary without exitLogDir → skipped (observability off)', () => {
|
|
75
84
|
expect(ensureServerCanary({ identity: 'claude-none', sock: '/tmp/none.sock' })).toBe('skipped')
|
|
76
85
|
})
|
|
86
|
+
|
|
87
|
+
test('canaryProcessPattern is identity-anchored (no prefix bleed) and matches both canary processes', () => {
|
|
88
|
+
const p = canaryProcessPattern('claude-iap')
|
|
89
|
+
expect(p).toBe('iap-canary-claude-iap([^a-z0-9-]|$)')
|
|
90
|
+
const re = new RegExp(p)
|
|
91
|
+
expect(re.test(`/opt/homebrew/bin/tmux -S /tmp/x.sock wait-for iap-canary-claude-iap`)).toBe(true) // client argv
|
|
92
|
+
expect(re.test(`/bin/sh -c ... wait-for 'iap-canary-claude-iap'\n...`)).toBe(true) // sh -c script (quoted channel)
|
|
93
|
+
expect(re.test(`tmux wait-for iap-canary-claude-iap-memory`)).toBe(false) // prefix identity must NOT match
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('dismissCanary is a harmless no-op when no canary runs', () => {
|
|
97
|
+
expect(() => dismissCanary('claude-nobody-here')).not.toThrow()
|
|
98
|
+
})
|
|
77
99
|
})
|
|
78
100
|
|
|
79
101
|
describe.if(tmuxAvailable)('canary live (sandbox tmux servers)', () => {
|
|
@@ -90,11 +112,22 @@ describe.if(tmuxAvailable)('canary live (sandbox tmux servers)', () => {
|
|
|
90
112
|
return { sock, logDir: join(dir, `logs-${identity}`) }
|
|
91
113
|
}
|
|
92
114
|
|
|
115
|
+
const allIds = [
|
|
116
|
+
'claude-canadirty',
|
|
117
|
+
'claude-canaclean',
|
|
118
|
+
'claude-canakill',
|
|
119
|
+
'notifier-canatear',
|
|
120
|
+
'claude-canasweep',
|
|
121
|
+
'claude-canaclient',
|
|
122
|
+
]
|
|
123
|
+
|
|
93
124
|
afterAll(() => {
|
|
94
125
|
for (const sock of socks) {
|
|
95
|
-
// teardown is DELIBERATE →
|
|
96
|
-
|
|
126
|
+
// teardown is DELIBERATE → silence each canary (signal + dismiss) before
|
|
127
|
+
// killing its server — the v2 contract for every deliberate path.
|
|
128
|
+
for (const id of allIds) {
|
|
97
129
|
signalCanaryClean(sock, id)
|
|
130
|
+
dismissCanary(id)
|
|
98
131
|
}
|
|
99
132
|
spawnSync('tmux', ['-S', sock, 'kill-server'], { stdio: 'ignore' })
|
|
100
133
|
}
|
|
@@ -197,4 +230,59 @@ describe.if(tmuxAvailable)('canary live (sandbox tmux servers)', () => {
|
|
|
197
230
|
},
|
|
198
231
|
20000,
|
|
199
232
|
)
|
|
233
|
+
|
|
234
|
+
test(
|
|
235
|
+
'death-#4 shape: external killer sweeps server AND canary client → ev=server-exit cause=client-signaled',
|
|
236
|
+
async () => {
|
|
237
|
+
const identity = 'claude-canasweep'
|
|
238
|
+
const { sock, logDir } = bringUp(identity)
|
|
239
|
+
const pid = serverPid(sock)
|
|
240
|
+
expect(ensureServerCanary({ identity, sock, exitLogDir: logDir })).toBe('spawned')
|
|
241
|
+
expect(await waitFor(() => canaryRunning(identity), 3000)).toBe(true)
|
|
242
|
+
await sleep(500)
|
|
243
|
+
|
|
244
|
+
// The pre-clean-shaped external killer: one pattern takes the server AND
|
|
245
|
+
// the canary CLIENT (both argv contain `tmux -S <sock> `), the sh recorder
|
|
246
|
+
// survives. The TERMed client returns rc=0 (proven live) — v1 read that as
|
|
247
|
+
// a clean channel signal and stayed silent; exactly how deaths #4–#6
|
|
248
|
+
// (10.06) left zero records. v2 probes the server instead: dead → record.
|
|
249
|
+
spawnSync('pkill', ['-f', `tmux -S ${sock} `], { stdio: 'ignore' })
|
|
250
|
+
const log = exitLogPath(logDir)
|
|
251
|
+
expect(
|
|
252
|
+
await waitFor(() => existsSync(log) && readFileSync(log, 'utf8').includes('ev=server-exit'), 10000),
|
|
253
|
+
).toBe(true)
|
|
254
|
+
const line = readFileSync(log, 'utf8')
|
|
255
|
+
expect(line).toContain(`ev=server-exit identity=${identity}`)
|
|
256
|
+
expect(line).toContain('cause=signaled-server-gone') // rc=0 shape, server found dead
|
|
257
|
+
expect(line).toContain(`server_pid=${pid}`)
|
|
258
|
+
expect(readdirSync(serverDeathsDir(logDir)).length).toBe(1) // forensics captured
|
|
259
|
+
},
|
|
260
|
+
20000,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
test(
|
|
264
|
+
'canary client killed while server lives → silent (no false record), canary gone for the retrofit to re-arm',
|
|
265
|
+
async () => {
|
|
266
|
+
const identity = 'claude-canaclient'
|
|
267
|
+
const { sock, logDir } = bringUp(identity)
|
|
268
|
+
expect(ensureServerCanary({ identity, sock, exitLogDir: logDir })).toBe('spawned')
|
|
269
|
+
expect(await waitFor(() => canaryRunning(identity), 3000)).toBe(true)
|
|
270
|
+
await sleep(500)
|
|
271
|
+
|
|
272
|
+
// TERM the CLIENT only (argv ends with the bare channel — the sh's quoted
|
|
273
|
+
// form does not match this $-anchored pattern), server stays up.
|
|
274
|
+
spawnSync('pkill', ['-f', `wait-for ${canaryChannel(identity)}$`], { stdio: 'ignore' })
|
|
275
|
+
// the sh probes (≈2 s), finds the server ALIVE → exits silently
|
|
276
|
+
expect(await waitFor(() => !canaryRunning(identity), 6000)).toBe(true)
|
|
277
|
+
await sleep(300)
|
|
278
|
+
expect(
|
|
279
|
+
existsSync(exitLogPath(logDir)) && readFileSync(exitLogPath(logDir), 'utf8').includes('ev=server-exit'),
|
|
280
|
+
).toBe(false) // no false server-death while the server lives
|
|
281
|
+
// server must still be alive — and ensure re-arms a fresh canary (the
|
|
282
|
+
// retrofit path; in prod the supervise tick does this and logs it)
|
|
283
|
+
expect(spawnSync('tmux', ['-S', sock, 'has-session', '-t', identity], { stdio: 'ignore' }).status).toBe(0)
|
|
284
|
+
expect(ensureServerCanary({ identity, sock, exitLogDir: logDir })).toBe('spawned')
|
|
285
|
+
},
|
|
286
|
+
20000,
|
|
287
|
+
)
|
|
200
288
|
})
|
package/src/launch/canary.ts
CHANGED
|
@@ -8,17 +8,32 @@
|
|
|
8
8
|
// Mechanism: one tiny detached `sh` per LIVE tmux server, holding a blocking
|
|
9
9
|
// client `tmux -S <sock> wait-for iap-canary-<identity>` — a process OUTSIDE the
|
|
10
10
|
// dying server, connected via its socket, so the server's death (any cause,
|
|
11
|
-
// including SIGKILL) is observed the moment the connection drops. Protocol
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// •
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// (
|
|
20
|
-
//
|
|
11
|
+
// including SIGKILL) is observed the moment the connection drops. Protocol (v2 —
|
|
12
|
+
// death #4 10.06 15:42Z proved v1's exit-code trust wrong twice over: the killer
|
|
13
|
+
// took the SERVER and the canary CLIENT together, AND a TERMed tmux client
|
|
14
|
+
// returns rc=0 — indistinguishable from a clean channel signal — so the rc-based
|
|
15
|
+
// `0 || ≥128 → silent` guard silenced a real death):
|
|
16
|
+
// • DELIBERATE silence is an explicit act, never an exit-code inference:
|
|
17
|
+
// every deliberate teardown (idle-reap / stop / pre-clean / pane-died hook /
|
|
18
|
+
// bootout teardown) signals the channel (`wait-for -S`) AND dismisses the
|
|
19
|
+
// sh recorder (`dismissCanary` → TERM → trap → silent exit). POSIX trap
|
|
20
|
+
// semantics make this race-free: the trap runs before any recording.
|
|
21
|
+
// • When wait-for returns — ANY code — the script sleeps 2 s (a concurrent
|
|
22
|
+
// dismissal TERM wins here), then probes SERVER LIVENESS: alive → exit
|
|
23
|
+
// silently (a lost canary is re-armed and logged by the supervise retrofit
|
|
24
|
+
// within a tick); dead with nobody having dismissed us → the death is real
|
|
25
|
+
// and unclaimed → ONE logfmt line `ev=server-exit` into exits.log (the
|
|
26
|
+
// per-peer death-cause home, next to pane-died's `ev=session-exit`) + a
|
|
27
|
+
// forensics snapshot (vm_stat / swap / top-RSS ps / fresh DiagnosticReports)
|
|
28
|
+
// captured within seconds — the evidence the 60 s supervise tick can never
|
|
21
29
|
// recover ("системных следов ноль" was the recurring investigation outcome).
|
|
30
|
+
// The raw wait_rc still ATTRIBUTES the death (cause=server-vanished /
|
|
31
|
+
// signaled-server-gone / client-killed-server-gone).
|
|
32
|
+
// Residual blind spot (structural): a killer that SIGKILLs the sh recorder
|
|
33
|
+
// itself leaves no in-process way to record. With v2 the ABSENCE of a record on
|
|
34
|
+
// a server-dead reap narrows the diagnosis to exactly that shape; the canary
|
|
35
|
+
// ensure-state lines in lifecycle.log (origin=launch/retrofit) evidence the
|
|
36
|
+
// churn post-hoc.
|
|
22
37
|
//
|
|
23
38
|
// The canary is pure observability: it never wakes, reaps, restarts or otherwise
|
|
24
39
|
// manages anything (H4-compatible by construction), it fires at most once, and
|
|
@@ -69,24 +84,45 @@ export function canaryScript(o: CanaryScriptOptions): string {
|
|
|
69
84
|
return [
|
|
70
85
|
// Server PID captured while alive — the postmortem grep key for system logs.
|
|
71
86
|
`SPID="$('${o.tmuxBin}' -S '${o.sock}' display-message -p '#{pid}' 2>/dev/null)"`,
|
|
72
|
-
// A signal to the
|
|
87
|
+
// A signal to the SH WRAPPER is deliberate dismissal (dismissCanary) or host
|
|
88
|
+
// shutdown → silent. POSIX: the trap runs after the foreground command
|
|
89
|
+
// completes — so a TERM delivered during wait-for/sleep always exits us
|
|
90
|
+
// BEFORE any recording below (the race-free deliberate-silence guarantee).
|
|
73
91
|
`trap 'exit 0' HUP INT TERM`,
|
|
74
92
|
`'${o.tmuxBin}' -S '${o.sock}' wait-for '${ch}'`,
|
|
75
93
|
`rc=$?`,
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
//
|
|
94
|
+
// NO wait-for exit code is trusted as "deliberate" by itself — PROVEN live
|
|
95
|
+
// (death-#4 postmortem): a TERM to the tmux CLIENT returns rc=0, identical
|
|
96
|
+
// to a clean channel signal, so an external killer sweeping server+client
|
|
97
|
+
// rides the clean-looking code straight past any rc-based guard (v1's
|
|
98
|
+
// `rc=0 || rc>=128 → silent` was exactly that hole). v2 contract instead:
|
|
99
|
+
// • deliberate teardowns DISMISS this sh (TERM → trap above) — the sleep
|
|
100
|
+
// below gives a concurrently-delivered dismissal time to win;
|
|
101
|
+
// • then the SERVER's liveness, not the exit code, decides: alive →
|
|
102
|
+
// nothing to record (a lost canary is re-armed and logged by the
|
|
103
|
+
// supervise retrofit within a tick); dead and nobody dismissed us →
|
|
104
|
+
// the death is real and unclaimed → record it.
|
|
105
|
+
`sleep 2`,
|
|
106
|
+
`if '${o.tmuxBin}' -S '${o.sock}' has-session 2>/dev/null; then exit 0; fi`,
|
|
107
|
+
// The exit code still ATTRIBUTES the recorded death (raw wait_rc is kept):
|
|
108
|
+
// rc=0 → signaled-server-gone (channel signal or client-TERM, server died)
|
|
109
|
+
// rc≥128 → client-killed-server-gone (client took a non-TERM kill)
|
|
110
|
+
// else → server-vanished (connection drop — SIGKILL/OOM class)
|
|
111
|
+
`cause=server-vanished`,
|
|
112
|
+
`if [ "$rc" -eq 0 ]; then cause=signaled-server-gone; fi`,
|
|
113
|
+
`if [ "$rc" -ge 128 ]; then cause=client-killed-server-gone; fi`,
|
|
114
|
+
// The server is gone under us — record, within seconds of the death.
|
|
79
115
|
`ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"`,
|
|
80
116
|
`f='${o.forensicsDir}/${o.identity}'-"$(date +%s)".txt`,
|
|
81
117
|
`{`,
|
|
82
|
-
` echo "server-death identity=${o.identity} ts=$ts server_pid=$SPID wait_rc=$rc"`,
|
|
118
|
+
` echo "server-death identity=${o.identity} ts=$ts server_pid=$SPID wait_rc=$rc cause=$cause"`,
|
|
83
119
|
` echo '--- vm_stat'; vm_stat`,
|
|
84
120
|
` echo '--- swapusage'; sysctl -n vm.swapusage`,
|
|
85
121
|
` echo '--- ps-top-rss'; ps axo pid,ppid,rss,etime,command | sort -rn -k3 | head -25`,
|
|
86
122
|
` echo '--- diagnosticreports-user'; ls -t "$HOME/Library/Logs/DiagnosticReports" 2>/dev/null | head`,
|
|
87
123
|
` echo '--- diagnosticreports-system'; ls -t /Library/Logs/DiagnosticReports 2>/dev/null | head`,
|
|
88
124
|
`} > "$f" 2>&1`,
|
|
89
|
-
`printf 'ts=%s ev=server-exit identity=${o.identity} server_pid=%s wait_rc=%s forensics=%s\\n' "$ts" "$SPID" "$rc" "$f" >> '${o.exitLogFile}'`,
|
|
125
|
+
`printf 'ts=%s ev=server-exit identity=${o.identity} server_pid=%s wait_rc=%s cause=%s forensics=%s\\n' "$ts" "$SPID" "$rc" "$cause" "$f" >> '${o.exitLogFile}'`,
|
|
90
126
|
].join('\n')
|
|
91
127
|
}
|
|
92
128
|
|
|
@@ -150,3 +186,27 @@ export function signalCanaryClean(sock: string, identity: string): void {
|
|
|
150
186
|
/* best-effort */
|
|
151
187
|
}
|
|
152
188
|
}
|
|
189
|
+
|
|
190
|
+
/** The pgrep/pkill ERE matching BOTH canary processes of ONE identity — the sh
|
|
191
|
+
* wrapper (its -c script quotes the channel: `…'iap-canary-<id>'…`) and the
|
|
192
|
+
* tmux client (argv ends with the bare channel). Anchored so an identity can
|
|
193
|
+
* never match another identity's prefix (claude-iapeer ≠ claude-iapeer-memory). */
|
|
194
|
+
export function canaryProcessPattern(identity: string): string {
|
|
195
|
+
return `${canaryChannel(identity)}([^a-z0-9-]|$)`
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Dismiss this identity's canary BEFORE a deliberate server teardown: TERM the
|
|
200
|
+
* sh wrapper (trap → silent exit, race-free — the trap always runs before the
|
|
201
|
+
* v2 recording branch) and the tmux client. The explicit counterpart of the
|
|
202
|
+
* channel signal: with v2 a signaled CLIENT alone is no longer read as
|
|
203
|
+
* deliberate, so every deliberate path must dismiss the RECORDER (the sh).
|
|
204
|
+
* Best-effort: no canary running → harmless no-op (pkill exits 1).
|
|
205
|
+
*/
|
|
206
|
+
export function dismissCanary(identity: string): void {
|
|
207
|
+
try {
|
|
208
|
+
spawnSync('pkill', ['-f', canaryProcessPattern(identity)], { stdio: 'ignore' })
|
|
209
|
+
} catch {
|
|
210
|
+
/* best-effort */
|
|
211
|
+
}
|
|
212
|
+
}
|
package/src/launch/index.ts
CHANGED
|
@@ -19,7 +19,8 @@ import { dirname } from 'path'
|
|
|
19
19
|
import { spawnSync } from 'child_process'
|
|
20
20
|
import { CODEX_BEARER_ENV_VAR, CODEX_DUMMY_BEARER, type Runtime } from '../core/constants.ts'
|
|
21
21
|
import { readLaunchEnv } from '../storage/index.ts'
|
|
22
|
-
import { ensureServerCanary, exitLogPath, signalCanaryClean } from './canary.ts'
|
|
22
|
+
import { canaryChannel, dismissCanary, ensureServerCanary, exitLogPath, signalCanaryClean } from './canary.ts'
|
|
23
|
+
import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
|
|
23
24
|
import { claudeAdapter } from './adapters/claude.ts'
|
|
24
25
|
import { codexAdapter } from './adapters/codex.ts'
|
|
25
26
|
import { telegramAdapter } from './adapters/telegram.ts'
|
|
@@ -155,7 +156,18 @@ export function exitCauseHook(identity: string, exitLogFile: string): string {
|
|
|
155
156
|
`dead_status=#{pane_dead_status} dead_signal=#{pane_dead_signal}\\n`
|
|
156
157
|
const log =
|
|
157
158
|
`printf "${line}" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${exitLogFile}"`
|
|
158
|
-
|
|
159
|
+
// Silence the server-death canary BEFORE kill-session: with a single-session
|
|
160
|
+
// server the kill empties the server and exit-empty takes it down — without
|
|
161
|
+
// explicit silence the canary would append a second, muddier `ev=server-exit`
|
|
162
|
+
// record for a death this very hook just captured (the session-exit line is
|
|
163
|
+
// the richer, authoritative one: it has the exit code/signal). v2 contract:
|
|
164
|
+
// deliberate = channel signal (tmux-NATIVE wait-for -S) + DISMISS the sh
|
|
165
|
+
// recorder (abs-path /usr/bin/pkill — survives the minimal launchd PATH;
|
|
166
|
+
// `\$` keeps the regex anchor literal through the sh double-quote layer; the
|
|
167
|
+
// `[i]` class keeps the pattern from matching its OWN occurrence in this
|
|
168
|
+
// hook-sh's cmdline — the pgrep self-match classic).
|
|
169
|
+
const dismiss = `/usr/bin/pkill -f "[i]${canaryChannel(identity).slice(1)}([^a-z0-9-]|\\$)"`
|
|
170
|
+
return `run-shell '${log} ; ${dismiss}' ; wait-for -S "${canaryChannel(identity)}" ; kill-session -t "${identity}"`
|
|
159
171
|
}
|
|
160
172
|
|
|
161
173
|
/** Install the exit-cause observability on a freshly-created session: ensure the
|
|
@@ -212,11 +224,15 @@ export const launch: LaunchFn = async (
|
|
|
212
224
|
mkdirSync(dirname(sock), { recursive: true })
|
|
213
225
|
|
|
214
226
|
// (1) Pre-clean any stale tmux server on this socket, then launch detached.
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
227
|
+
// Silence the server-death canary EXPLICITLY first — this teardown is
|
|
228
|
+
// deliberate: signal the channel (client exits 0) AND dismiss the sh
|
|
229
|
+
// recorder (TERM → trap → silent; canary v2 no longer reads a signaled
|
|
230
|
+
// client alone as deliberate — an external killer sweeping server+client
|
|
231
|
+
// was exactly the death-#4 silence). Only then sweep the server processes
|
|
232
|
+
// (the pkill also matches a leftover canary client — harmless, both
|
|
233
|
+
// canary processes are already dismissed).
|
|
219
234
|
signalCanaryClean(sock, identity)
|
|
235
|
+
dismissCanary(identity)
|
|
220
236
|
spawnSync('pkill', ['-f', `tmux -S ${sock} `], { stdio: 'ignore' })
|
|
221
237
|
tmux(sock, 'kill-server')
|
|
222
238
|
|
|
@@ -268,7 +284,14 @@ export const launch: LaunchFn = async (
|
|
|
268
284
|
// that records `ev=server-exit` + a forensics snapshot when the whole
|
|
269
285
|
// server dies dirty (SIGKILL/OOM class). Same gate as the exit hook;
|
|
270
286
|
// best-effort by construction (every failure → a state, never a throw).
|
|
271
|
-
|
|
287
|
+
// The ensure-state is logged (origin=launch) so the arming trail is
|
|
288
|
+
// complete in lifecycle.log: 'spawned' is the newborn-server norm here,
|
|
289
|
+
// 'failed' a newborn server starting its life UNWATCHED (death-#4
|
|
290
|
+
// postmortem hinged on this very question being unanswerable).
|
|
291
|
+
const canaryState = ensureServerCanary({ identity, sock, exitLogDir: cfg.exitLogDir, env })
|
|
292
|
+
if (canaryState !== 'skipped') {
|
|
293
|
+
appendLifecycleEvent(cfg.exitLogDir, { ev: 'canary', identity, state: canaryState, origin: 'launch' }, { env })
|
|
294
|
+
}
|
|
272
295
|
|
|
273
296
|
// (3) pipe-pane the session output to the per-identity log.
|
|
274
297
|
mkdirSync(cfg.logDir, { recursive: true, mode: 0o700 })
|
|
@@ -227,6 +227,14 @@ describe('exitCauseHook (exit-cause observability)', () => {
|
|
|
227
227
|
// tmux-NATIVE kill-session (no shell `tmux`) → needs no PATH (launchd minimal env).
|
|
228
228
|
expect(hook).toContain('kill-session -t "claude-iapeer"')
|
|
229
229
|
})
|
|
230
|
+
test('silences the server-death canary (native wait-for -S) BEFORE kill-session — no double record', () => {
|
|
231
|
+
// kill-session on a single-session server → exit-empty takes the server down;
|
|
232
|
+
// without the signal the canary would add a second `ev=server-exit` record for
|
|
233
|
+
// a death this hook just captured (session-exit carries the code/signal).
|
|
234
|
+
expect(hook).toContain('wait-for -S "iap-canary-claude-iapeer"')
|
|
235
|
+
expect(hook.indexOf('wait-for -S')).toBeLessThan(hook.indexOf('kill-session'))
|
|
236
|
+
expect(hook.indexOf('run-shell')).toBeLessThan(hook.indexOf('wait-for -S')) // log first
|
|
237
|
+
})
|
|
230
238
|
test('quoting: single-quoted run-shell arg (tmux layer) wrapping double-quoted sh', () => {
|
|
231
239
|
expect(hook).toMatch(/run-shell '.*'/)
|
|
232
240
|
expect(hook).not.toContain("''") // no empty/again-collapsed single-quote pair
|
package/src/launch/launchdRun.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { buildProcessAddress, buildSocketPath } from '../core/socket.ts'
|
|
|
23
23
|
import { peerLogsDir, pluginLogsDir } from '../storage/index.ts'
|
|
24
24
|
import { readPeerProfile } from '../identity/index.ts'
|
|
25
25
|
import { getAdapter, launch } from './index.ts'
|
|
26
|
-
import { signalCanaryClean } from './canary.ts'
|
|
26
|
+
import { dismissCanary, signalCanaryClean } from './canary.ts'
|
|
27
27
|
import type { LaunchConfig, LaunchSpec } from './types.ts'
|
|
28
28
|
|
|
29
29
|
/** Block-watch poll cadence — seconds, deliberately NOT a tight loop (the session
|
|
@@ -55,7 +55,10 @@ function sessionAlive(sock: string, identity: string): boolean {
|
|
|
55
55
|
* both paths now converge on the same end state.
|
|
56
56
|
*/
|
|
57
57
|
export function teardownAlwaysOnSession(sock: string, identity: string): void {
|
|
58
|
+
// Explicit canary silence (v2): channel signal (client exits 0) + dismiss the
|
|
59
|
+
// sh recorder — a signaled client alone is no longer read as deliberate.
|
|
58
60
|
signalCanaryClean(sock, identity)
|
|
61
|
+
dismissCanary(identity)
|
|
59
62
|
spawnSync('tmux', ['-S', sock, 'kill-session', '-t', identity], { stdio: 'ignore' })
|
|
60
63
|
const ls = spawnSync('tmux', ['-S', sock, 'list-sessions', '-F', '#{session_name}'], { encoding: 'utf8' })
|
|
61
64
|
if (!(ls.stdout ?? '').trim()) {
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -46,7 +46,7 @@ import {
|
|
|
46
46
|
type LaunchSpec,
|
|
47
47
|
} from '../launch/index.ts'
|
|
48
48
|
import { composeSystemPrompt, gatherPromptInput } from '../launch/composeSystemPrompt.ts'
|
|
49
|
-
import { ensureServerCanary, signalCanaryClean } from '../launch/canary.ts'
|
|
49
|
+
import { dismissCanary, ensureServerCanary, signalCanaryClean } from '../launch/canary.ts'
|
|
50
50
|
import { appendLifecycleEvent, superviseLogVerbose } from './eventlog.ts'
|
|
51
51
|
|
|
52
52
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -1009,9 +1009,11 @@ export function killSession(sock: string, identity: string): void {
|
|
|
1009
1009
|
tmux(sock, 'kill-session', '-t', identity)
|
|
1010
1010
|
const sessions = tmux(sock, 'list-sessions', '-F', '#{session_name}').out
|
|
1011
1011
|
if (!sessions.trim()) {
|
|
1012
|
-
// Deliberate server teardown →
|
|
1013
|
-
// exits
|
|
1012
|
+
// Deliberate server teardown → silence the canary EXPLICITLY first: signal
|
|
1013
|
+
// the channel (client exits 0) AND dismiss the sh recorder (canary v2 no
|
|
1014
|
+
// longer reads a signaled client alone as deliberate — see canary.ts).
|
|
1014
1015
|
signalCanaryClean(sock, identity)
|
|
1016
|
+
dismissCanary(identity)
|
|
1015
1017
|
tmux(sock, 'kill-server')
|
|
1016
1018
|
try {
|
|
1017
1019
|
rmSync(sock, { force: true })
|
|
@@ -1190,7 +1192,15 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
|
|
|
1190
1192
|
// every ALIVE daemon-owned server — covers a fleet launched by older code
|
|
1191
1193
|
// within one tick of a deploy, no session restarts. Idempotent (pgrep on
|
|
1192
1194
|
// the per-identity wait-for channel), best-effort, pure observability.
|
|
1193
|
-
|
|
1195
|
+
// A non-'already' state IS a decision-grade event (not verbose-gated):
|
|
1196
|
+
// post-0.2.22 every launch arms a canary, so a retrofit 'spawned' on an
|
|
1197
|
+
// alive server means the previous canary VANISHED mid-watch (the death-#4
|
|
1198
|
+
// blind spot was exactly this churn being invisible); 'failed' means the
|
|
1199
|
+
// server is currently UNWATCHED. At most one line per loss, not per tick.
|
|
1200
|
+
const canaryState = ensureServerCanary({ identity: s.identity, sock, exitLogDir: cfg.eventLogDir, env })
|
|
1201
|
+
if (canaryState !== 'already') {
|
|
1202
|
+
trace({ identity: s.identity, action: 'canary', state: canaryState, origin: 'retrofit' })
|
|
1203
|
+
}
|
|
1194
1204
|
out.push({ identity: s.identity, action: 'alive' })
|
|
1195
1205
|
if (verbose) trace({ identity: s.identity, action: 'alive', age: `${ageSecs}s` })
|
|
1196
1206
|
}
|
package/src/provision/index.ts
CHANGED
|
@@ -121,13 +121,59 @@ export async function provisionPeer(opts: ProvisionPeerOptions): Promise<Provisi
|
|
|
121
121
|
)
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
|
+
// Provider PROVISION command (контракт §Provision провайдера, declaration
|
|
125
|
+
// v1.2 — ADR-009 inversion; решение Артура 10.06, схема сверена с
|
|
126
|
+
// iapeer-memory 11.06): an occupied slot WITH a provision block shells into
|
|
127
|
+
// the PROVIDER's command per agentic runtime (occasion=birth) — the core
|
|
128
|
+
// knows nothing about surface forms. PRECEDENCE: provision WINS over the
|
|
129
|
+
// deprecated v1.1 plugin block, with NO runtime fallback to plugin on
|
|
130
|
+
// failure (masking a provision refusal would hide a broken provider —
|
|
131
|
+
// LOUD warn + repair-hint instead). Runs AFTER preTrustCodexCwd above, so
|
|
132
|
+
// the provider's per-peer codex surfaces land in an already-trusted cwd.
|
|
133
|
+
if (slot.provision) {
|
|
134
|
+
const { runProvisionCommand } = await import('../enable/provisionCommand.ts')
|
|
135
|
+
const { appendLifecycleEvent } = await import('../lifecycle/eventlog.ts')
|
|
136
|
+
const { pluginLogsDir } = await import('../storage/index.ts')
|
|
137
|
+
const agentic = profile.runtimes.filter((r): r is 'claude' | 'codex' => r === 'claude' || r === 'codex')
|
|
138
|
+
for (const rt of agentic) {
|
|
139
|
+
const o = runProvisionCommand({
|
|
140
|
+
block: slot.provision,
|
|
141
|
+
cwd,
|
|
142
|
+
runtime: rt,
|
|
143
|
+
personality: profile.personality,
|
|
144
|
+
occasion: 'birth',
|
|
145
|
+
env,
|
|
146
|
+
})
|
|
147
|
+
appendLifecycleEvent(
|
|
148
|
+
pluginLogsDir('iapeer', { env }),
|
|
149
|
+
{
|
|
150
|
+
ev: 'memory-provision',
|
|
151
|
+
identity: `${rt}-${profile.personality}`,
|
|
152
|
+
occasion: 'birth',
|
|
153
|
+
state: o.state,
|
|
154
|
+
exit: o.exitCode ?? undefined,
|
|
155
|
+
ms: o.durationMs,
|
|
156
|
+
detail: o.detail,
|
|
157
|
+
},
|
|
158
|
+
{ env },
|
|
159
|
+
)
|
|
160
|
+
if (o.state !== 'ok') {
|
|
161
|
+
opts.warn?.(
|
|
162
|
+
`memory provision (${rt}) ${o.state.toUpperCase()} for "${profile.personality}"` +
|
|
163
|
+
`${o.detail ? `: ${o.detail}` : ''} — память пира не подключена; ` +
|
|
164
|
+
`repair: iapeer memory-plugin on --peer ${profile.personality}`,
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
124
169
|
// Provider PLUGIN auto-install (контракт §Плагин провайдера, declaration
|
|
125
|
-
// v1.1; agreed iapeer-memory + boris 10.06): an
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
170
|
+
// v1.1, DEPRECATED by §Provision; agreed iapeer-memory + boris 10.06): an
|
|
171
|
+
// occupied slot WITH a plugin block and WITHOUT a provision block installs
|
|
172
|
+
// the provider's marketplace plugin into the newborn's scope (claude
|
|
173
|
+
// project-scope in cwd; codex host-global, idempotent) — «создал пира —
|
|
174
|
+
// память работает». A v1 declaration (no block) installs nothing.
|
|
129
175
|
// Best-effort with a LOUD warn; repair = `iapeer memory-plugin on --peer`.
|
|
130
|
-
if (slot.plugin) {
|
|
176
|
+
else if (slot.plugin) {
|
|
131
177
|
const { enableCapabilityForCwd } = await import('../enable/index.ts')
|
|
132
178
|
const agentic = profile.runtimes.filter((r): r is 'claude' | 'codex' => r === 'claude' || r === 'codex')
|
|
133
179
|
if (agentic.length > 0) {
|
|
@@ -223,4 +223,67 @@ describe('provisionPeer birth-time native-memory lever', () => {
|
|
|
223
223
|
// the native lever still applied (same slot-gated block)
|
|
224
224
|
expect(JSON.parse(readFileSync(join(cwd, '.claude', 'settings.json'), 'utf8')).autoMemoryEnabled).toBe(false)
|
|
225
225
|
})
|
|
226
|
+
|
|
227
|
+
test('v1.2: slot with provision command → birth shells into it per runtime (occasion=birth), plugin path NOT taken', async () => {
|
|
228
|
+
const root = mkTmp()
|
|
229
|
+
const env = envFor(root)
|
|
230
|
+
mkdirSync(join(root, 'iapeer'), { recursive: true })
|
|
231
|
+
const journal = join(root, 'journal.txt')
|
|
232
|
+
const script = join(root, 'fake-provider.sh')
|
|
233
|
+
const { chmodSync } = await import('fs')
|
|
234
|
+
writeFileSync(script, `#!/bin/sh\nprintf '%s\\n' "$@" >> '${journal}'\n`)
|
|
235
|
+
chmodSync(script, 0o755)
|
|
236
|
+
writeFileSync(
|
|
237
|
+
join(root, 'iapeer', 'memory-provider.json'),
|
|
238
|
+
JSON.stringify({
|
|
239
|
+
provider: 'iapeer-memory',
|
|
240
|
+
package: '@agfpd/iapeer-memory',
|
|
241
|
+
version: '0.2.0',
|
|
242
|
+
registeredAt: 'x',
|
|
243
|
+
// BOTH blocks declared → provision must WIN (precedence, no plugin attempt)
|
|
244
|
+
plugin: { name: 'iapeer-memory', marketplace: 'agfpd', marketplaceRef: 'agfpd/agfpd-marketplace' },
|
|
245
|
+
provision: {
|
|
246
|
+
command: script,
|
|
247
|
+
args: ['provision-peer', '--cwd', '{cwd}', '--runtime', '{runtime}', '--personality', '{personality}', '--occasion', '{occasion}'],
|
|
248
|
+
},
|
|
249
|
+
}),
|
|
250
|
+
)
|
|
251
|
+
const cwd = join(root, 'wprov')
|
|
252
|
+
const warns: string[] = []
|
|
253
|
+
const r = await provisionPeer({ cwd, runtime: 'claude', env, warn: m => warns.push(m) })
|
|
254
|
+
expect(r.personality).toBe('wprov')
|
|
255
|
+
expect(warns).toEqual([]) // provision ok → no warn at all
|
|
256
|
+
const j = readFileSync(journal, 'utf8').split('\n')
|
|
257
|
+
expect(j[0]).toBe('provision-peer')
|
|
258
|
+
expect(j.slice(1, 9)).toEqual(['--cwd', cwd, '--runtime', 'claude', '--personality', 'wprov', '--occasion', 'birth'])
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
test('v1.2: provision command FAILS → LOUD warn with repair-hint, provision of the peer still SUCCEEDS (best-effort)', async () => {
|
|
262
|
+
const root = mkTmp()
|
|
263
|
+
const env = envFor(root)
|
|
264
|
+
mkdirSync(join(root, 'iapeer'), { recursive: true })
|
|
265
|
+
const script = join(root, 'fake-provider.sh')
|
|
266
|
+
const { chmodSync } = await import('fs')
|
|
267
|
+
writeFileSync(script, `#!/bin/sh\necho 'vault offline' >&2\nexit 9\n`)
|
|
268
|
+
chmodSync(script, 0o755)
|
|
269
|
+
writeFileSync(
|
|
270
|
+
join(root, 'iapeer', 'memory-provider.json'),
|
|
271
|
+
JSON.stringify({
|
|
272
|
+
provider: 'iapeer-memory',
|
|
273
|
+
package: '@agfpd/iapeer-memory',
|
|
274
|
+
version: '0.2.0',
|
|
275
|
+
registeredAt: 'x',
|
|
276
|
+
provision: { command: script, args: ['{occasion}'] },
|
|
277
|
+
}),
|
|
278
|
+
)
|
|
279
|
+
const cwd = join(root, 'wfail')
|
|
280
|
+
const warns: string[] = []
|
|
281
|
+
const r = await provisionPeer({ cwd, runtime: 'claude', env, warn: m => warns.push(m) })
|
|
282
|
+
expect(r.personality).toBe('wfail') // birth completed despite the provider hiccup
|
|
283
|
+
const w = warns.find(x => x.includes('memory provision'))
|
|
284
|
+
expect(w).toBeDefined()
|
|
285
|
+
expect(w).toContain('FAILED')
|
|
286
|
+
expect(w).toContain('vault offline')
|
|
287
|
+
expect(w).toContain('memory-plugin on --peer wfail') // repair-hint
|
|
288
|
+
})
|
|
226
289
|
})
|
package/src/status/index.ts
CHANGED
|
@@ -32,6 +32,21 @@ export interface MemoryProviderPlugin {
|
|
|
32
32
|
marketplaceRef: string
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/** The provider's provision command (declaration v1.2, контракт §Provision
|
|
36
|
+
* провайдера — ADR-009 inversion, решение Артура 10.06 + дизайн-консенсус
|
|
37
|
+
* сторон). The slot declares not surfaces but a COMMAND the core shells into at
|
|
38
|
+
* the lifecycle joints (birth / verb sweeps / remove); surface forms and their
|
|
39
|
+
* uninstall accounting live entirely with the provider. The `command` must be
|
|
40
|
+
* ABSOLUTE (the daemon runs under launchd's minimal PATH); `args` get PER-ARG
|
|
41
|
+
* placeholder substitution ({cwd}/{runtime}/{personality}/{occasion}) and are
|
|
42
|
+
* spawned WITHOUT a shell (injection/quoting class — бэктик-грабли 10.06). */
|
|
43
|
+
export interface MemoryProviderProvision {
|
|
44
|
+
/** Absolute path to the provider's executable. */
|
|
45
|
+
command: string
|
|
46
|
+
/** Argv tail; placeholders are substituted per-argument, never shell-parsed. */
|
|
47
|
+
args: string[]
|
|
48
|
+
}
|
|
49
|
+
|
|
35
50
|
export interface MemoryProvider {
|
|
36
51
|
/** Provider name occupying the slot (e.g. "iapeer-memory"). */
|
|
37
52
|
provider: string
|
|
@@ -42,8 +57,15 @@ export interface MemoryProvider {
|
|
|
42
57
|
/** Optional liveness proxy: an absolute path whose mtime the provider's daemon
|
|
43
58
|
* refreshes. status reports its age; the core takes NO action on staleness. */
|
|
44
59
|
heartbeat?: string
|
|
45
|
-
/** Optional marketplace-plugin declaration (v1.1) — see MemoryProviderPlugin.
|
|
60
|
+
/** Optional marketplace-plugin declaration (v1.1) — see MemoryProviderPlugin.
|
|
61
|
+
* DEPRECATED by v1.2: when `provision` is also present, provision WINS and
|
|
62
|
+
* the plugin block is ignored (no runtime fallback — an explicit precedence,
|
|
63
|
+
* agreed iapeer-memory 11.06). */
|
|
46
64
|
plugin?: MemoryProviderPlugin
|
|
65
|
+
/** Optional v1.2 provision command (peer-birth / sweep-on joints). */
|
|
66
|
+
provision?: MemoryProviderProvision
|
|
67
|
+
/** Optional v1.2 unprovision command (off-peer / off-all / remove joints). */
|
|
68
|
+
unprovision?: MemoryProviderProvision
|
|
47
69
|
}
|
|
48
70
|
|
|
49
71
|
/** Parse the optional v1.1 `plugin` block; anything short of three non-empty
|
|
@@ -57,6 +79,18 @@ function parsePluginBlock(raw: unknown): MemoryProviderPlugin | undefined {
|
|
|
57
79
|
return { name: o.name.trim(), marketplace: o.marketplace.trim(), marketplaceRef: o.marketplaceRef.trim() }
|
|
58
80
|
}
|
|
59
81
|
|
|
82
|
+
/** Parse an optional v1.2 `provision`/`unprovision` block. Fail-open like the
|
|
83
|
+
* plugin block: anything short of an ABSOLUTE command string + an array of
|
|
84
|
+
* strings → undefined (treated as absent). A relative command is INVALID by
|
|
85
|
+
* contract (launchd minimal PATH would resolve it differently per caller). */
|
|
86
|
+
function parseProvisionBlock(raw: unknown): MemoryProviderProvision | undefined {
|
|
87
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined
|
|
88
|
+
const o = raw as Record<string, unknown>
|
|
89
|
+
if (typeof o.command !== 'string' || !o.command.trim().startsWith('/')) return undefined
|
|
90
|
+
if (!Array.isArray(o.args) || o.args.some(a => typeof a !== 'string')) return undefined
|
|
91
|
+
return { command: o.command.trim(), args: (o.args as string[]).slice() }
|
|
92
|
+
}
|
|
93
|
+
|
|
60
94
|
export function memoryProviderPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
61
95
|
return join(resolveGlobalRoot(env), MEMORY_PROVIDER_FILE)
|
|
62
96
|
}
|
|
@@ -74,6 +108,8 @@ export function readMemoryProvider(env: NodeJS.ProcessEnv = process.env): Memory
|
|
|
74
108
|
if (typeof o.package !== 'string' || !o.package.trim()) return null
|
|
75
109
|
if (typeof o.version !== 'string' || !o.version.trim()) return null
|
|
76
110
|
const plugin = parsePluginBlock(o.plugin)
|
|
111
|
+
const provision = parseProvisionBlock(o.provision)
|
|
112
|
+
const unprovision = parseProvisionBlock(o.unprovision)
|
|
77
113
|
return {
|
|
78
114
|
provider: o.provider.trim(),
|
|
79
115
|
package: o.package.trim(),
|
|
@@ -81,6 +117,8 @@ export function readMemoryProvider(env: NodeJS.ProcessEnv = process.env): Memory
|
|
|
81
117
|
registeredAt: typeof o.registeredAt === 'string' ? o.registeredAt : '',
|
|
82
118
|
...(typeof o.heartbeat === 'string' && o.heartbeat.trim() ? { heartbeat: o.heartbeat.trim() } : {}),
|
|
83
119
|
...(plugin ? { plugin } : {}),
|
|
120
|
+
...(provision ? { provision } : {}),
|
|
121
|
+
...(unprovision ? { unprovision } : {}),
|
|
84
122
|
}
|
|
85
123
|
} catch {
|
|
86
124
|
return null // empty slot — bare core is valid
|
|
@@ -81,6 +81,33 @@ describe('readMemoryProvider (slot declaration, fail-open)', () => {
|
|
|
81
81
|
expect(p?.plugin).toBeUndefined()
|
|
82
82
|
}
|
|
83
83
|
})
|
|
84
|
+
|
|
85
|
+
test('v1.2 provision/unprovision blocks: parsed when valid; relative command or bad args = absent (fail-open)', () => {
|
|
86
|
+
const root = mkTmp()
|
|
87
|
+
const env = envFor(root)
|
|
88
|
+
const provision = { command: '/usr/local/bin/iapeer-memory', args: ['provision-peer', '--cwd', '{cwd}'] }
|
|
89
|
+
const unprovision = { command: '/usr/local/bin/iapeer-memory', args: ['unprovision-peer'] }
|
|
90
|
+
writeFileSync(memoryProviderPath(env), JSON.stringify({ ...VALID, provision, unprovision }))
|
|
91
|
+
const p = readMemoryProvider(env)
|
|
92
|
+
expect(p?.provision).toEqual(provision)
|
|
93
|
+
expect(p?.unprovision).toEqual(unprovision)
|
|
94
|
+
// empty args array is VALID (command alone)
|
|
95
|
+
writeFileSync(memoryProviderPath(env), JSON.stringify({ ...VALID, provision: { command: '/x/y', args: [] } }))
|
|
96
|
+
expect(readMemoryProvider(env)?.provision).toEqual({ command: '/x/y', args: [] })
|
|
97
|
+
// invalid shapes → block absent, declaration itself stays valid
|
|
98
|
+
for (const bad of [
|
|
99
|
+
{ command: 'iapeer-memory', args: [] }, // RELATIVE command (launchd minimal PATH) — contract-invalid
|
|
100
|
+
{ command: '/x/y' }, // args missing
|
|
101
|
+
{ command: '/x/y', args: ['ok', 42] }, // non-string arg
|
|
102
|
+
{ args: ['provision-peer'] }, // command missing
|
|
103
|
+
'provision-me', // not an object
|
|
104
|
+
]) {
|
|
105
|
+
writeFileSync(memoryProviderPath(env), JSON.stringify({ ...VALID, provision: bad }))
|
|
106
|
+
const r = readMemoryProvider(env)
|
|
107
|
+
expect(r).not.toBeNull()
|
|
108
|
+
expect(r?.provision).toBeUndefined()
|
|
109
|
+
}
|
|
110
|
+
})
|
|
84
111
|
})
|
|
85
112
|
|
|
86
113
|
describe('heartbeatAgeSecs', () => {
|