@agfpd/iapeer 0.4.125 → 0.4.127
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/docs/19-notices.md +40 -1
- package/package.json +1 -1
- package/src/cli/index.ts +15 -0
- package/src/core/constants.ts +11 -0
- package/src/daemon/limitresume.ts +98 -0
- package/src/daemon/main.ts +38 -40
- package/src/daemon/mutewatch.ts +31 -6
- package/src/identity/index.ts +28 -0
- package/src/launch/adapters/opencode.ts +3 -0
- package/src/launch/index.ts +8 -2
- package/src/lifecycle/index.ts +16 -0
- package/src/lifecycle/inflight.ts +7 -4
- package/src/lifecycle/limitpark.ts +163 -0
- package/src/opencode/iapeer-plugin.mjs +4 -0
- package/src/registry/index.ts +4 -0
package/docs/19-notices.md
CHANGED
|
@@ -191,7 +191,11 @@ thread, no self-fresh, and auto-continuation genuinely resumed.
|
|
|
191
191
|
A notice states who, which runtime, which error type, which model, and when the wall lifts.
|
|
192
192
|
Two of those are **absent by design rather than guessed**:
|
|
193
193
|
|
|
194
|
-
- **`resetsAtMs` on claude
|
|
194
|
+
- **`resetsAtMs` on claude — only when stated.** An ACCOUNT-level wall (five-hour / weekly) IS
|
|
195
|
+
stated structurally on the refusal line (measured 18.10.2026, claude 2.1.302:
|
|
196
|
+
`quotaLimits.resetsAt` and `apiErrorParams.rate_limit_info.resetsAt`, epoch seconds,
|
|
197
|
+
`rateLimitType: "five_hour"`) — the notice carries it and §9 acts on it. A **per-model
|
|
198
|
+
bucket** states none: claude does not say when a per-model bucket lifts. Its
|
|
195
199
|
`errorDetails` is a raw 429 reading *"Please try again later"* — no stamp. The 5h/7d
|
|
196
200
|
`resets_at` in the statusline blob belongs to a **different limit** and must never be
|
|
197
201
|
substituted: measured live on 15.07.2026, the 5h bucket sat at **11 %** and the 7d at
|
|
@@ -431,3 +435,38 @@ unanswered rather than inferred from the 429 result.**
|
|
|
431
435
|
provider path could in principle record something this one did not.
|
|
432
436
|
|
|
433
437
|
Artifacts (rollout, injector, config): `docs/internals/forensics/model-limit-2026-07-15/`.
|
|
438
|
+
|
|
439
|
+
## 9. Limit-resume — a peer stopped by a limit continues by itself after the reset
|
|
440
|
+
|
|
441
|
+
**Invariant.** A peer whose turn was stopped by an exhausted model limit that STATED its reset
|
|
442
|
+
continues after that reset without a human. Incident 18.10.2026: the fleet hit the claude
|
|
443
|
+
five-hour wall at 04:55; claude painted «resets 8:30am», the daemon idle-reaped the sessions at
|
|
444
|
+
~05:55, nothing woke them after the reset and every peer stood still until a manual ping at 10:12.
|
|
445
|
+
|
|
446
|
+
**Mechanism** (`lifecycle/limitpark.ts`, `daemon/limitresume.ts`):
|
|
447
|
+
|
|
448
|
+
1. **Park.** A `peer-mute` detection that carries `resetsAtMs` writes one record per endpoint
|
|
449
|
+
(`state/lifecycle/limit-parks/<runtime>-<personality>.json`: reset, event time, session file).
|
|
450
|
+
A newer limit event supersedes it; the overlapping sweep re-reading the same line does not.
|
|
451
|
+
No stated reset → no park (the notice still tells the owner; nothing is extrapolated).
|
|
452
|
+
2. **Hold.** While `now < resetsAt + 3 min` the idle-reap skips the session (`outcome=limit-hold`,
|
|
453
|
+
traced once per park): the context stays warm and the runtime's own continuation can fire.
|
|
454
|
+
Bounded: a reset more than 6 h after the event (a weekly wall) is not held — the session is
|
|
455
|
+
reaped normally and step 3 resumes it.
|
|
456
|
+
3. **Nudge.** After `resetsAt + 3 min`, if the session made no progress since the event (claude: a
|
|
457
|
+
real, non-`<synthetic>` assistant line after it; codex: a `task_complete` with a message whose
|
|
458
|
+
`completed_at` is after it), the core delivers ONE continuation message through the normal
|
|
459
|
+
route — wake-on-miss resumes a reaped session (an idle reap is resume-eligible). Progress
|
|
460
|
+
already made → the park is dropped without a message (no duplicate turn). A failed delivery is
|
|
461
|
+
retried (5 min backoff, 3 attempts); an operator `stop` is never fought.
|
|
462
|
+
4. **Sender** = the core's system identity `iapeer-system` (runtime `core`, intelligence
|
|
463
|
+
`absent`): synthesized, never registered, the name reserved by the registry — so it exists on
|
|
464
|
+
every install and is never the target (the target may be the peer named `iapeer` itself). The
|
|
465
|
+
same identity now signs the OC07 interrupted/undelivered notices and the composer-queue
|
|
466
|
+
failure notices.
|
|
467
|
+
|
|
468
|
+
Every decision is in `lifecycle.log`: `ev=limit-park`, `outcome=limit-hold`, `ev=limit-resume
|
|
469
|
+
outcome=nudged|progressed|retry|gave-up|dropped`.
|
|
470
|
+
|
|
471
|
+
**Not covered:** opencode (its `session.error` states no reset) and a claude per-model bucket
|
|
472
|
+
(no reset stated) — both still raise the notice; the owner resumes by hand.
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -1448,6 +1448,21 @@ export async function renamePeerCli(
|
|
|
1448
1448
|
sideEffects.push(`claude .mcp.json: failed (${e instanceof Error ? e.message : String(e)})`)
|
|
1449
1449
|
}
|
|
1450
1450
|
}
|
|
1451
|
+
if (peer.runtimes.includes('opencode')) {
|
|
1452
|
+
// The opencode private dir is keyed by personality (docs/20): move it with the peer, or the endpoint
|
|
1453
|
+
// loses its session (resume), journal history and provider/memory/capability fragments.
|
|
1454
|
+
try {
|
|
1455
|
+
const { opencodePrivateDir } = await import('../storage/index.ts')
|
|
1456
|
+
const from = opencodePrivateDir(oldPersonality, { env })
|
|
1457
|
+
const to = opencodePrivateDir(newPersonality, { env })
|
|
1458
|
+
if (existsSync(from) && !existsSync(to)) {
|
|
1459
|
+
renameSync(from, to)
|
|
1460
|
+
sideEffects.push(`opencode private dir moved: ${to}`)
|
|
1461
|
+
} else if (existsSync(from)) sideEffects.push(`opencode private dir NOT moved: ${to} already exists`)
|
|
1462
|
+
} catch (e) {
|
|
1463
|
+
sideEffects.push(`opencode private dir: failed (${e instanceof Error ? e.message : String(e)})`)
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1451
1466
|
const purgedState = peer.runtimes.flatMap(rt => purgeIdentityState(cfg, buildProcessAddress(rt, oldPersonality)))
|
|
1452
1467
|
return { oldPersonality, newPersonality, action: 'renamed', oldCwd, newCwd, transcriptMoved, purgedState, sideEffects }
|
|
1453
1468
|
}
|
package/src/core/constants.ts
CHANGED
|
@@ -237,3 +237,14 @@ export function isValidName(value: unknown): boolean {
|
|
|
237
237
|
// module so the transliteration engine is swappable behind one interface; it is
|
|
238
238
|
// re-exported here so existing `from '../core/constants.ts'` importers are unchanged.
|
|
239
239
|
export { normalizeNameCandidate } from './normalize.ts'
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The CORE's own sender identity — the author of daemon-originated messages (OC07 interrupted /
|
|
243
|
+
* undelivered notices, the composer-queue failure outlet, the limit-resume nudge). It exists on EVERY
|
|
244
|
+
* install by construction: it is synthesized, never registered, so a host with no peer of any given
|
|
245
|
+
* name still gets its system notices (a registry-dependent sender was skipped silently there).
|
|
246
|
+
* The name is RESERVED: the registry refuses to register a peer under it, so it can never be spoofed
|
|
247
|
+
* by — or collide with — a real peer. Intelligence `absent`: a recipient does not answer it.
|
|
248
|
+
*/
|
|
249
|
+
export const SYSTEM_PERSONALITY = 'iapeer-system'
|
|
250
|
+
export const SYSTEM_RUNTIME = 'core'
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Limit-resume — the NUDGE half of the limit-park contract (lifecycle/limitpark.ts, docs/19 §9).
|
|
2
|
+
//
|
|
3
|
+
// Invariant: a peer stopped by an exhausted model limit mid-task continues by itself after the
|
|
4
|
+
// stated reset — no human. Run after every supervise tick: for each park whose reset + grace has
|
|
5
|
+
// passed, either the session already progressed (the runtime's own continuation fired, or a later
|
|
6
|
+
// message got a real reply) → the park is dropped; or it did not → the core delivers ONE
|
|
7
|
+
// continuation message through the normal route. Wake-on-miss resumes a reaped session (an idle
|
|
8
|
+
// reap is resume-eligible), so the same message serves a held-alive and a reaped session.
|
|
9
|
+
//
|
|
10
|
+
// Sender = the core's system identity (identity/systemCaller): registry-independent, and never the
|
|
11
|
+
// target — the target may well be the peer named `iapeer` itself (it was, on 18.10.2026).
|
|
12
|
+
|
|
13
|
+
import type { LifecycleConfig } from '../lifecycle/index.ts'
|
|
14
|
+
import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
|
|
15
|
+
import {
|
|
16
|
+
LIMIT_MAX_NUDGE_ATTEMPTS,
|
|
17
|
+
LIMIT_RESUME_GRACE_MS,
|
|
18
|
+
clearLimitPark,
|
|
19
|
+
listLimitParks,
|
|
20
|
+
parkProgressed,
|
|
21
|
+
updateLimitPark,
|
|
22
|
+
type LimitPark,
|
|
23
|
+
} from '../lifecycle/limitpark.ts'
|
|
24
|
+
import { systemCaller } from '../identity/index.ts'
|
|
25
|
+
import { routeSend, type RouteDeps } from '../transport/index.ts'
|
|
26
|
+
|
|
27
|
+
/** Between two failed nudge attempts. */
|
|
28
|
+
const RETRY_BACKOFF_MS = 5 * 60_000
|
|
29
|
+
|
|
30
|
+
export function limitResumeMessage(p: LimitPark): string {
|
|
31
|
+
return (
|
|
32
|
+
`[iapeer] Your model limit (${p.errorType ?? 'rate limit'}) has reset — it lifted at ${new Date(p.resetsAtMs).toISOString()}. ` +
|
|
33
|
+
`Your previous turn was stopped by that limit at ${new Date(p.eventAtMs).toISOString()} and nothing has continued it since. ` +
|
|
34
|
+
`Resume the interrupted work from where it stopped (re-check its state first). ` +
|
|
35
|
+
`If that work is already finished, no reply is needed — this notice comes from the iapeer core, not a peer.`
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface LimitResumeSeam {
|
|
40
|
+
send?: typeof routeSend
|
|
41
|
+
progressed?: (p: LimitPark) => boolean
|
|
42
|
+
now?: () => number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function processLimitResumes(
|
|
46
|
+
cfg: LifecycleConfig,
|
|
47
|
+
env: NodeJS.ProcessEnv,
|
|
48
|
+
routeDeps: RouteDeps,
|
|
49
|
+
seam: LimitResumeSeam = {},
|
|
50
|
+
): Promise<void> {
|
|
51
|
+
const now = (seam.now ?? Date.now)()
|
|
52
|
+
const send = seam.send ?? routeSend
|
|
53
|
+
const progressed = seam.progressed ?? parkProgressed
|
|
54
|
+
const log = (fields: Record<string, string | number | undefined>): void => {
|
|
55
|
+
try {
|
|
56
|
+
appendLifecycleEvent(cfg.eventLogDir, { ev: 'limit-resume', ...fields }, { env })
|
|
57
|
+
} catch {
|
|
58
|
+
/* best-effort */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const p of listLimitParks(cfg)) {
|
|
62
|
+
if (now < p.resetsAtMs + LIMIT_RESUME_GRACE_MS) continue
|
|
63
|
+
if (p.lastAttemptMs && now - p.lastAttemptMs < RETRY_BACKOFF_MS) continue
|
|
64
|
+
let moved = false
|
|
65
|
+
try {
|
|
66
|
+
moved = progressed(p)
|
|
67
|
+
} catch {
|
|
68
|
+
moved = false
|
|
69
|
+
}
|
|
70
|
+
if (moved) {
|
|
71
|
+
clearLimitPark(cfg, p.identity)
|
|
72
|
+
log({ identity: p.identity, outcome: 'progressed', resets: new Date(p.resetsAtMs).toISOString() })
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
let reason: string
|
|
76
|
+
try {
|
|
77
|
+
const r = await send(systemCaller(), { personality: p.personality, runtime: p.runtime, message: limitResumeMessage(p), topic: 'limit-resume' }, routeDeps)
|
|
78
|
+
if (r.ok) {
|
|
79
|
+
clearLimitPark(cfg, p.identity)
|
|
80
|
+
log({ identity: p.identity, outcome: 'nudged', woke: String(r.value.woke), resets: new Date(p.resetsAtMs).toISOString() })
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
reason = r.error.message
|
|
84
|
+
} catch (e) {
|
|
85
|
+
reason = e instanceof Error ? e.message : String(e)
|
|
86
|
+
}
|
|
87
|
+
const attempts = (p.attempts ?? 0) + 1
|
|
88
|
+
// A deliberate operator halt (stopped) is not a transient — never fight it.
|
|
89
|
+
const permanent = /\bstopped\b|not in the iapeer peers index/.test(reason)
|
|
90
|
+
if (permanent || attempts >= LIMIT_MAX_NUDGE_ATTEMPTS) {
|
|
91
|
+
clearLimitPark(cfg, p.identity)
|
|
92
|
+
log({ identity: p.identity, outcome: permanent ? 'dropped' : 'gave-up', attempts, err: reason.slice(0, 300) })
|
|
93
|
+
} else {
|
|
94
|
+
updateLimitPark(cfg, { ...p, attempts, lastAttemptMs: now })
|
|
95
|
+
log({ identity: p.identity, outcome: 'retry', attempts, err: reason.slice(0, 300) })
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
package/src/daemon/main.ts
CHANGED
|
@@ -51,9 +51,9 @@ import {
|
|
|
51
51
|
import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
|
|
52
52
|
import { buildProcessAddress } from '../core/socket.ts'
|
|
53
53
|
import { err, ok } from '../core/errors.ts'
|
|
54
|
-
import { resolveCallerIdentity } from '../identity/index.ts'
|
|
54
|
+
import { resolveCallerIdentity, systemCaller } from '../identity/index.ts'
|
|
55
55
|
import type { ResolvedCaller } from '../identity/index.ts'
|
|
56
|
-
import {
|
|
56
|
+
import { readPeersIndex } from '../registry/index.ts'
|
|
57
57
|
import {
|
|
58
58
|
createComposerDeliveryQueue,
|
|
59
59
|
deliveryCompleted,
|
|
@@ -74,6 +74,8 @@ import { appendDeliveryEvent } from './deliverylog.ts'
|
|
|
74
74
|
import { buildFleetHandler } from './fleet.ts'
|
|
75
75
|
import { NoticeBoard } from './notices.ts'
|
|
76
76
|
import { startMuteWatch } from './mutewatch.ts'
|
|
77
|
+
import { processLimitResumes } from './limitresume.ts'
|
|
78
|
+
import { recordLimitPark } from '../lifecycle/limitpark.ts'
|
|
77
79
|
import { DEFAULT_PROFILE_SYNC_INTERVAL_MS, startProfileSync } from './profilesync.ts'
|
|
78
80
|
import { startGoalWatch } from './goalwatch.ts'
|
|
79
81
|
import { ActivityBoard, startTurnWatch } from './turnwatch.ts'
|
|
@@ -307,14 +309,12 @@ export async function processInflight(
|
|
|
307
309
|
): Promise<void> {
|
|
308
310
|
const processed = seam.processed ?? ((e: InflightEntry) => deliveryProcessed(e, env))
|
|
309
311
|
const send = seam.send ?? routeSend
|
|
310
|
-
/** A notice FROM
|
|
312
|
+
/** A notice FROM the core's system identity to the entry's original sender. Registry-independent
|
|
313
|
+
* (systemCaller is synthesized), so it is deliverable on every install — the earlier sender was the
|
|
314
|
+
* peer named `iapeer`, and a host without it skipped the notice silently (linus 18.10). */
|
|
311
315
|
const notifySender = async (e: InflightEntry, message: string): Promise<string> => {
|
|
312
316
|
try {
|
|
313
|
-
const
|
|
314
|
-
const self = findPeer(index, 'iapeer')
|
|
315
|
-
if (!self || `${e.caller.runtime}-${e.caller.personality}` === `${self.runtime}-iapeer`) return 'skipped'
|
|
316
|
-
const sender = resolveCallerIdentity({ personality: 'iapeer', runtime: self.runtime }, index)
|
|
317
|
-
const n = await send(sender, { personality: e.caller.personality, runtime: e.caller.runtime, message, topic: e.topic ?? 'delivery-failed' }, routeDeps)
|
|
317
|
+
const n = await send(systemCaller(), { personality: e.caller.personality, runtime: e.caller.runtime, message, topic: e.topic ?? 'delivery-failed' }, routeDeps)
|
|
318
318
|
return String(n.ok)
|
|
319
319
|
} catch {
|
|
320
320
|
return 'false'
|
|
@@ -384,38 +384,9 @@ export function makeComposerQueueRouteDeps(cfg: LifecycleConfig, env: NodeJS.Pro
|
|
|
384
384
|
noteInflight: makeNoteInflight(cfg),
|
|
385
385
|
onDelivered,
|
|
386
386
|
notifyFailed: async (job, reason) => {
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
appendDeliveryEvent(cfg.eventLogDir, {
|
|
391
|
-
ev: 'composer-queue-failed-notify',
|
|
392
|
-
caller: 'iapeer',
|
|
393
|
-
to: job.caller.personality,
|
|
394
|
-
rt: job.caller.runtime,
|
|
395
|
-
ok: 'false',
|
|
396
|
-
err: 'foundation peer "iapeer" is not registered; cannot notify queued sender',
|
|
397
|
-
})
|
|
398
|
-
return
|
|
399
|
-
}
|
|
400
|
-
const sender = resolveCallerIdentity({ personality: 'iapeer', runtime: self.runtime }, index)
|
|
401
|
-
// SELF-NOTIFY GUARD: the failure outlet sends FROM the foundation peer (iapeer). When the QUEUED
|
|
402
|
-
// sender IS the foundation peer on the same runtime (e.g. iapeer itself queued a cross-runtime send to
|
|
403
|
-
// its codex twin and that queue later failed), this notify would be iapeer→iapeer — a genuine
|
|
404
|
-
// self-send the address-based router correctly refuses with 'cannot send to self', logging ok=false
|
|
405
|
-
// and tripping monitoring alarms. The notification is moot anyway (the sender IS the one being
|
|
406
|
-
// notified about its own send). Skip it and log a benign skip instead of attempting a self-send.
|
|
407
|
-
if (`${job.caller.runtime}-${job.caller.personality}` === sender.address) {
|
|
408
|
-
appendDeliveryEvent(cfg.eventLogDir, {
|
|
409
|
-
ev: 'composer-queue-failed-notify',
|
|
410
|
-
caller: sender.address,
|
|
411
|
-
to: job.caller.personality,
|
|
412
|
-
rt: job.caller.runtime,
|
|
413
|
-
ok: 'true',
|
|
414
|
-
err: 'skipped self-notify (queued sender is the foundation peer itself)',
|
|
415
|
-
topic: job.topic ?? 'delivery-failed',
|
|
416
|
-
})
|
|
417
|
-
return
|
|
418
|
-
}
|
|
387
|
+
// The core's system identity — exists on every install and is never a peer, so there is no
|
|
388
|
+
// missing-sender case and no self-send to guard (it used to be the peer named `iapeer`).
|
|
389
|
+
const sender = systemCaller()
|
|
419
390
|
const result = await routeSend(
|
|
420
391
|
sender,
|
|
421
392
|
{
|
|
@@ -508,6 +479,23 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
|
|
|
508
479
|
appendLifecycleEvent(cfg.eventLogDir, { ev: 'mutewatch-error', error: detail.replace(/\s+/g, ' ').slice(0, 600) }, { env })
|
|
509
480
|
} catch { /* best-effort */ }
|
|
510
481
|
},
|
|
482
|
+
// Limit-park (docs/19 §9): a limit event that STATES its reset parks the endpoint — the supervise
|
|
483
|
+
// tick holds it from idle-reap and the limit-resume pass nudges it after the reset.
|
|
484
|
+
onLimit: d => {
|
|
485
|
+
const identity = `${d.runtime}-${d.personality}`
|
|
486
|
+
if (d.resetsAtMs === undefined) return
|
|
487
|
+
const fresh = recordLimitPark(cfg, {
|
|
488
|
+
identity,
|
|
489
|
+
personality: d.personality,
|
|
490
|
+
runtime: d.runtime,
|
|
491
|
+
resetsAtMs: d.resetsAtMs,
|
|
492
|
+
eventAtMs: d.atMs,
|
|
493
|
+
...(d.file ? { file: d.file } : {}),
|
|
494
|
+
...(d.sessionId ? { sessionId: d.sessionId } : {}),
|
|
495
|
+
errorType: d.errorType,
|
|
496
|
+
})
|
|
497
|
+
if (fresh) appendLifecycleEvent(cfg.eventLogDir, { ev: 'limit-park', identity, resets: new Date(d.resetsAtMs).toISOString(), error_type: d.errorType }, { env })
|
|
498
|
+
},
|
|
511
499
|
})
|
|
512
500
|
// Goal-watch (docs/19) — the SAME board and the same class as the mute-watch: a peer left
|
|
513
501
|
// alive and green while the thing it was told to do quietly stopped. Separate detector
|
|
@@ -627,6 +615,16 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
|
|
|
627
615
|
noteInflight,
|
|
628
616
|
isLaunchdManaged: (personality: string) => isLaunchdManaged(personality, env),
|
|
629
617
|
})
|
|
618
|
+
// Limit-resume (docs/19 §9): a peer stopped by a model limit continues after the reset.
|
|
619
|
+
await processLimitResumes(cfg, env, {
|
|
620
|
+
env,
|
|
621
|
+
wake: makeWakeFn(cfg, env),
|
|
622
|
+
ephemeral,
|
|
623
|
+
noteLiveTopic,
|
|
624
|
+
noteDelivered: identity => setLastDelivered(cfg, identity),
|
|
625
|
+
noteInflight,
|
|
626
|
+
isLaunchdManaged: (personality: string) => isLaunchdManaged(personality, env),
|
|
627
|
+
})
|
|
630
628
|
},
|
|
631
629
|
// Surface a supervise-tick throw that the daemon timer would otherwise SWALLOW (the class that
|
|
632
630
|
// hid a stuck reaper for hours): record ev=supervise-error in lifecycle.log, flattened to one
|
package/src/daemon/mutewatch.ts
CHANGED
|
@@ -102,9 +102,11 @@ export interface MuteDetection {
|
|
|
102
102
|
sessionId?: string
|
|
103
103
|
atMs: number
|
|
104
104
|
cwd: string
|
|
105
|
+
/** The session file the event was read from (the limit-park progress probe re-reads it). */
|
|
106
|
+
file?: string
|
|
105
107
|
}
|
|
106
108
|
|
|
107
|
-
type RawDetection = Omit<MuteDetection, 'personality' | 'runtime'>
|
|
109
|
+
type RawDetection = Omit<MuteDetection, 'personality' | 'runtime' | 'file'>
|
|
108
110
|
|
|
109
111
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
110
112
|
// Pure parsers — string in, detection out (fs-free: tests need no temp tree)
|
|
@@ -165,12 +167,24 @@ export function parseClaudeTranscript(text: string, sinceMs: number): RawDetecti
|
|
|
165
167
|
sessionId: typeof d.sessionId === 'string' ? d.sessionId : undefined,
|
|
166
168
|
atMs,
|
|
167
169
|
cwd,
|
|
168
|
-
//
|
|
170
|
+
// The reset ONLY when claude states it structurally (account-level walls, observed 18.10.2026 on
|
|
171
|
+
// claude 2.1.302: `quotaLimits.resetsAt` / `apiErrorParams.rate_limit_info.resetsAt`, epoch s,
|
|
172
|
+
// rateLimitType five_hour). A per-model bucket states none («try again later») → omitted, never
|
|
173
|
+
// extrapolated from the other buckets.
|
|
174
|
+
...(claudeResetsAtMs(d) !== undefined ? { resetsAtMs: claudeResetsAtMs(d) } : {}),
|
|
169
175
|
}
|
|
170
176
|
}
|
|
171
177
|
return found
|
|
172
178
|
}
|
|
173
179
|
|
|
180
|
+
/** Claude's structurally stated reset instant (ms), or undefined. Epoch SECONDS on the wire. */
|
|
181
|
+
export function claudeResetsAtMs(d: Record<string, unknown>): number | undefined {
|
|
182
|
+
const q = d.quotaLimits as { resetsAt?: unknown } | undefined
|
|
183
|
+
const r = (d.apiErrorParams as { rate_limit_info?: { resetsAt?: unknown } } | undefined)?.rate_limit_info
|
|
184
|
+
const v = typeof q?.resetsAt === 'number' ? q.resetsAt : typeof r?.resetsAt === 'number' ? r.resetsAt : undefined
|
|
185
|
+
return v !== undefined && v > 0 ? v * 1000 : undefined
|
|
186
|
+
}
|
|
187
|
+
|
|
174
188
|
interface CodexWindow {
|
|
175
189
|
used_percent?: unknown
|
|
176
190
|
window_minutes?: unknown
|
|
@@ -463,21 +477,21 @@ export function resolveMuteWatchPaths(env: NodeJS.ProcessEnv): MuteWatchPaths {
|
|
|
463
477
|
export function scanMuteEvents(deps: ScanDeps, sinceMs: number): MuteDetection[] {
|
|
464
478
|
const floor = sinceMs - SCAN_OVERLAP_MS
|
|
465
479
|
const out: MuteDetection[] = []
|
|
466
|
-
const push = (raw: RawDetection | null, runtime: 'claude' | 'codex'): void => {
|
|
480
|
+
const push = (raw: RawDetection | null, runtime: 'claude' | 'codex', file: string): void => {
|
|
467
481
|
if (!raw) return
|
|
468
482
|
const personality = deps.peerByCwd(raw.cwd)
|
|
469
483
|
if (!personality) return // not a peer's session (a human's own shell) — never notify
|
|
470
|
-
out.push({ ...raw, personality, runtime })
|
|
484
|
+
out.push({ ...raw, personality, runtime, file })
|
|
471
485
|
}
|
|
472
486
|
// claude: ~/.claude/projects/<slug>/<sessionId>.jsonl — one level of slug dirs.
|
|
473
487
|
for (const file of changedJsonl(deps.claudeProjectsDir, floor, 1)) {
|
|
474
488
|
const t = readSessionSlices(file, TAIL_BYTES)
|
|
475
|
-
if (t) push(parseClaudeTranscript(t.tail, floor), 'claude')
|
|
489
|
+
if (t) push(parseClaudeTranscript(t.tail, floor), 'claude', file)
|
|
476
490
|
}
|
|
477
491
|
// codex: ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
|
|
478
492
|
for (const file of changedJsonl(deps.codexSessionsDir, floor, CODEX_WALK_DEPTH)) {
|
|
479
493
|
const t = readSessionSlices(file, TAIL_BYTES)
|
|
480
|
-
if (t) push(parseCodexRollout(t.tail, floor, t.head), 'codex')
|
|
494
|
+
if (t) push(parseCodexRollout(t.tail, floor, t.head), 'codex', file)
|
|
481
495
|
}
|
|
482
496
|
// opencode: the core-owned journal per endpoint (personality is known — no cwd mapping needed).
|
|
483
497
|
for (const j of deps.opencodeJournals?.() ?? []) {
|
|
@@ -500,6 +514,8 @@ export interface MuteWatchDeps extends ScanDeps {
|
|
|
500
514
|
now?: () => number
|
|
501
515
|
/** Reported, never thrown — a detector must never take the daemon down. */
|
|
502
516
|
onError?: (err: unknown) => void
|
|
517
|
+
/** Every detection that STATES a reset (deduped or not) — the limit-park writer (docs/19 §9). */
|
|
518
|
+
onLimit?: (d: MuteDetection) => void
|
|
503
519
|
}
|
|
504
520
|
|
|
505
521
|
/**
|
|
@@ -527,6 +543,13 @@ export function muteWatchTick(deps: MuteWatchDeps, sinceMs: number): { raised: n
|
|
|
527
543
|
summary: `${d.personality} · ${d.runtime} — ${d.errorType}${d.model ? ` (${d.model})` : ''}`,
|
|
528
544
|
})
|
|
529
545
|
if (!deduped) raised += 1
|
|
546
|
+
if (d.resetsAtMs !== undefined && deps.onLimit) {
|
|
547
|
+
try {
|
|
548
|
+
deps.onLimit(d)
|
|
549
|
+
} catch (e) {
|
|
550
|
+
deps.onError?.(e)
|
|
551
|
+
}
|
|
552
|
+
}
|
|
530
553
|
}
|
|
531
554
|
} catch (e) {
|
|
532
555
|
deps.onError?.(e)
|
|
@@ -543,6 +566,7 @@ export interface StartMuteWatchOptions {
|
|
|
543
566
|
listPeers?: () => Array<{ personality: string; cwd: string; runtimes?: string[] }>
|
|
544
567
|
now?: () => number
|
|
545
568
|
onError?: (err: unknown) => void
|
|
569
|
+
onLimit?: (d: MuteDetection) => void
|
|
546
570
|
}
|
|
547
571
|
|
|
548
572
|
/**
|
|
@@ -579,6 +603,7 @@ export function startMuteWatch(cfg: LifecycleConfig, board: NoticeBoard, opts: S
|
|
|
579
603
|
board,
|
|
580
604
|
now,
|
|
581
605
|
onError: opts.onError,
|
|
606
|
+
onLimit: opts.onLimit,
|
|
582
607
|
peerByCwd: (cwd: string) => byCwd.get(cwd.replace(/\/+$/, '').toLowerCase()),
|
|
583
608
|
opencodeJournals: () =>
|
|
584
609
|
rows
|
package/src/identity/index.ts
CHANGED
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
IAPEER_DIR,
|
|
12
12
|
PEER_PROFILE_FILE,
|
|
13
13
|
NAME_RE,
|
|
14
|
+
SYSTEM_PERSONALITY,
|
|
15
|
+
SYSTEM_RUNTIME,
|
|
14
16
|
allowedIntelligencesForRuntime,
|
|
15
17
|
defaultIntelligenceForRuntime,
|
|
16
18
|
isInfraRuntime,
|
|
@@ -781,6 +783,32 @@ export interface ResolvedCaller {
|
|
|
781
783
|
record: PeerRecord
|
|
782
784
|
}
|
|
783
785
|
|
|
786
|
+
/**
|
|
787
|
+
* The core's own sender (core/constants SYSTEM_PERSONALITY). Synthesized — never read from the
|
|
788
|
+
* registry — so daemon-originated notices are deliverable on every install, independent of which
|
|
789
|
+
* peers happen to be registered. Never a request identity: resolveCallerIdentity refuses it (the
|
|
790
|
+
* name cannot be registered), so no client can send AS the core.
|
|
791
|
+
*/
|
|
792
|
+
export function systemCaller(): ResolvedCaller {
|
|
793
|
+
const record: PeerRecord = {
|
|
794
|
+
personality: SYSTEM_PERSONALITY,
|
|
795
|
+
runtime: SYSTEM_RUNTIME,
|
|
796
|
+
runtimes: [SYSTEM_RUNTIME],
|
|
797
|
+
description: 'iapeer core — system notices (no reply needed)',
|
|
798
|
+
intelligence: 'absent',
|
|
799
|
+
cwd: '',
|
|
800
|
+
}
|
|
801
|
+
return {
|
|
802
|
+
personality: SYSTEM_PERSONALITY,
|
|
803
|
+
runtime: SYSTEM_RUNTIME,
|
|
804
|
+
address: buildProcessAddress(SYSTEM_RUNTIME, SYSTEM_PERSONALITY),
|
|
805
|
+
description: record.description,
|
|
806
|
+
intelligence: 'absent',
|
|
807
|
+
cwd: '',
|
|
808
|
+
record,
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
784
812
|
/**
|
|
785
813
|
* Resolve a caller identity carried IN THE REQUEST (X-IAPeer-Identity), against
|
|
786
814
|
* the registry only. Deliberately does NOT read process.cwd(), process.env, or
|
|
@@ -108,6 +108,9 @@ export const opencodeAdapter: RuntimeAdapter = {
|
|
|
108
108
|
const warnings = hasExplicitModel(config)
|
|
109
109
|
? []
|
|
110
110
|
: [`opencode-implicit-provider: no explicit "model" in ${p.config} — OpenCode's own default provider receives the doctrine and messages`]
|
|
111
|
+
// Handed to the plugin too, so they reach runtime.ready / state.json / fleet runtimes[].warnings like
|
|
112
|
+
// the self-check findings (not only lifecycle.log).
|
|
113
|
+
if (warnings.length) out.IAPEER_OPENCODE_WARNINGS = JSON.stringify(warnings)
|
|
111
114
|
return { env: out, readyToken: launchID, warnings }
|
|
112
115
|
},
|
|
113
116
|
|
package/src/launch/index.ts
CHANGED
|
@@ -194,13 +194,17 @@ export const launch: LaunchFn = async (
|
|
|
194
194
|
// Structural runtimes (opencode) prepare launch artifacts + extra env BEFORE spawn. The identity
|
|
195
195
|
// ABI stays authoritative: prepared env never overrides PEER_PERSONALITY/RUNTIME/IDENTITY.
|
|
196
196
|
let readyToken: string | undefined
|
|
197
|
+
const loggedWarnings = new Set<string>()
|
|
197
198
|
if (adapter.prepareLaunch) {
|
|
198
199
|
try {
|
|
199
200
|
const prepared = adapter.prepareLaunch(spec, invocation.env, cfg)
|
|
200
201
|
const { PEER_PERSONALITY, PEER_RUNTIME, PEER_IDENTITY } = invocation.env
|
|
201
202
|
invocation.env = { ...invocation.env, ...prepared.env, PEER_PERSONALITY, PEER_RUNTIME, PEER_IDENTITY }
|
|
202
203
|
readyToken = prepared.readyToken
|
|
203
|
-
for (const w of prepared.warnings ?? [])
|
|
204
|
+
for (const w of prepared.warnings ?? []) {
|
|
205
|
+
loggedWarnings.add(w)
|
|
206
|
+
appendLifecycleEvent(cfg.exitLogDir, { ev: 'launch-warning', identity, warning: w }, { env })
|
|
207
|
+
}
|
|
204
208
|
} catch (e) {
|
|
205
209
|
return fail(identity, `launch preparation failed: ${e instanceof Error ? e.message : String(e)}`)
|
|
206
210
|
}
|
|
@@ -241,7 +245,9 @@ export const launch: LaunchFn = async (
|
|
|
241
245
|
if (adapter.structuralReady && readyToken) {
|
|
242
246
|
const r = await adapter.structuralReady(spec, readyToken, Date.now() + cfg.bootDeadlineSecs * 1000, () => hostSessionAlive(identity, env), env)
|
|
243
247
|
if (!r.ok) return fail(identity, `never-became-ready (structural: ${r.reason ?? 'unknown'})`)
|
|
244
|
-
for (const w of r.warnings ?? [])
|
|
248
|
+
for (const w of r.warnings ?? []) {
|
|
249
|
+
if (!loggedWarnings.has(w)) appendLifecycleEvent(cfg.exitLogDir, { ev: 'launch-warning', identity, warning: w }, { env })
|
|
250
|
+
}
|
|
245
251
|
if (firstMessage.trim().length === 0) return ready(identity)
|
|
246
252
|
if (!adapter.deliver) return fail(identity, 'structural runtime has no deliver hook')
|
|
247
253
|
const d = await adapter.deliver({ personality: spec.personality, identity }, firstMessage, env)
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// before any wake or reap. wakeOrSpawn refuses a launchd peer; superviseTick and
|
|
13
13
|
// sweepZombies skip it. Only daemon-owned (no-plist) peers are managed here.
|
|
14
14
|
|
|
15
|
+
import { limitHoldActive } from './limitpark.ts'
|
|
15
16
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'fs'
|
|
16
17
|
import { homedir } from 'os'
|
|
17
18
|
import { join } from 'path'
|
|
@@ -1520,6 +1521,18 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
|
|
|
1520
1521
|
}
|
|
1521
1522
|
}
|
|
1522
1523
|
if (ageSecs > cfg.idleSecs) {
|
|
1524
|
+
// LIMIT HOLD (docs/19 §9) — a session stopped by an exhausted model limit that STATED its reset is
|
|
1525
|
+
// not idle, it is waiting: keep it (warm context; the runtime's own continuation gets to fire) until
|
|
1526
|
+
// the reset + grace. Bounded in limitpark.ts; past the bound the reap proceeds and the limit-resume
|
|
1527
|
+
// nudge wakes it (an idle reap is resume-eligible) — either way the turn is not lost.
|
|
1528
|
+
const held = limitHoldActive(cfg, s.identity, nowMs)
|
|
1529
|
+
if (held) {
|
|
1530
|
+
// Traced ONCE per park (the tick runs every minute; a 3 h hold would flood lifecycle.log).
|
|
1531
|
+
const key = `${s.identity}@${held.eventAtMs}`
|
|
1532
|
+
if (verbose || !limitHoldTraced.has(key)) trace({ identity: s.identity, action: 'alive', age: `${ageSecs}s`, outcome: 'limit-hold', resets: new Date(held.resetsAtMs).toISOString() })
|
|
1533
|
+
limitHoldTraced.add(key)
|
|
1534
|
+
return { identity: s.identity, action: 'alive', reason: `limit hold until ${new Date(held.resetsAtMs).toISOString()}` }
|
|
1535
|
+
}
|
|
1523
1536
|
// THE ONLY place .idle-reaped is written: this is the one death the daemon
|
|
1524
1537
|
// INITIATES. Its presence on the next wake = the session was parked cleanly =
|
|
1525
1538
|
// RESUME-eligible (resolveWakeMode branch 3b). A crash/self-close (the dead
|
|
@@ -1536,6 +1549,9 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
|
|
|
1536
1549
|
return { identity: s.identity, action: 'alive' }
|
|
1537
1550
|
}
|
|
1538
1551
|
|
|
1552
|
+
/** Parks whose hold was already traced (process-local; a daemon restart re-traces once — harmless). */
|
|
1553
|
+
const limitHoldTraced = new Set<string>()
|
|
1554
|
+
|
|
1539
1555
|
export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): SuperviseOutcome[] {
|
|
1540
1556
|
const env = deps.env ?? process.env
|
|
1541
1557
|
const nowMs = deps.nowMs ?? Date.now()
|
|
@@ -119,17 +119,20 @@ export function sweepInflight(stateDir: string, identity: string, deps: Inflight
|
|
|
119
119
|
}
|
|
120
120
|
}
|
|
121
121
|
// A session works its input in order: a FINISHED turn for a later message means every earlier one
|
|
122
|
-
// was handled too (bounds the ledger when a turn-end record is missed).
|
|
123
|
-
|
|
122
|
+
// OF THE SAME SESSION was handled too (bounds the ledger when a turn-end record is missed). Strictly
|
|
123
|
+
// per session token: a turn finished in a NEW session says nothing about a dead session's queue —
|
|
124
|
+
// that cross-session shortcut silently dropped A and B in 0.4.122 (linus OC07 regression).
|
|
125
|
+
const finishedUpTo = new Map<string, number>()
|
|
124
126
|
const finished = new Set<string>()
|
|
127
|
+
const sessionKey = (e: InflightEntry) => e.sessionToken ?? '∅'
|
|
125
128
|
for (const e of entries) {
|
|
126
129
|
if (safe(deps.completed, e, false) || (!deps.completed && safe(deps.processed, e, false))) {
|
|
127
130
|
finished.add(e.id)
|
|
128
|
-
finishedUpTo
|
|
131
|
+
finishedUpTo.set(sessionKey(e), Math.max(finishedUpTo.get(sessionKey(e)) ?? -Infinity, e.ts))
|
|
129
132
|
}
|
|
130
133
|
}
|
|
131
134
|
for (const e of entries) {
|
|
132
|
-
if (finished.has(e.id) || e.ts < finishedUpTo) continue
|
|
135
|
+
if (finished.has(e.id) || e.ts < (finishedUpTo.get(sessionKey(e)) ?? -Infinity)) continue
|
|
133
136
|
const done = safe(deps.processed, e, false)
|
|
134
137
|
if (done) {
|
|
135
138
|
// Answered but its turn did not finish: live → still working; gone → INTERRUPTED (side effects
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Limit-park — a peer whose turn was stopped by an exhausted model limit must CONTINUE on its own
|
|
2
|
+
// once the limit lifts (docs/19 §9). Incident 18.10.2026: the fleet hit the claude five-hour limit
|
|
3
|
+
// at 04:55; claude painted «resets 8:30am» in the pane, the daemon idle-reaped the sessions an hour
|
|
4
|
+
// later, nothing woke them after the reset, and every peer stood still until a human pinged it.
|
|
5
|
+
//
|
|
6
|
+
// TWO HALVES, one record per endpoint (identity = <runtime>-<personality>):
|
|
7
|
+
// • HOLD — while a park is live (now < resetsAt + grace) superviseTick does NOT idle-reap the
|
|
8
|
+
// session. The context stays warm and a runtime that resumes by itself (claude's own «Continuing
|
|
9
|
+
// automatically») gets the chance to. Bounded by MAX_HOLD_MS: a weekly wall is not worth a
|
|
10
|
+
// process held for days — beyond it the session is reaped normally and the nudge resumes it.
|
|
11
|
+
// • NUDGE — after resetsAt + grace, if the session made NO progress since the limit event, the
|
|
12
|
+
// daemon delivers a continuation message (daemon/limitresume.ts). Wake-on-miss resumes a reaped
|
|
13
|
+
// session (an idle reap is resume-eligible), so the nudge works whether the hold applied or not.
|
|
14
|
+
//
|
|
15
|
+
// The record is written by the mute-watch on a structural limit event that STATES a reset time —
|
|
16
|
+
// never extrapolated: no resetsAt, no park (the notice still tells the owner). A newer limit event
|
|
17
|
+
// for the same endpoint replaces the record (each event earns exactly one nudge).
|
|
18
|
+
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs'
|
|
20
|
+
import { join } from 'node:path'
|
|
21
|
+
import type { LifecycleConfig } from './index.ts'
|
|
22
|
+
import { writeFileAtomic } from '../storage/index.ts'
|
|
23
|
+
import { readSessionSlices } from '../daemon/sessionfiles.ts'
|
|
24
|
+
|
|
25
|
+
/** Wait this long past the stated reset before nudging: the runtime's own continuation (and a clock
|
|
26
|
+
* skew between the provider and this host) gets to win first. */
|
|
27
|
+
export const LIMIT_RESUME_GRACE_MS = 3 * 60_000
|
|
28
|
+
/** The hold never keeps a session alive longer than this past the limit event. */
|
|
29
|
+
export const LIMIT_MAX_HOLD_MS = 6 * 60 * 60_000
|
|
30
|
+
/** A failed nudge is retried at most this many times (then the park is dropped, logged). */
|
|
31
|
+
export const LIMIT_MAX_NUDGE_ATTEMPTS = 3
|
|
32
|
+
|
|
33
|
+
export interface LimitPark {
|
|
34
|
+
identity: string
|
|
35
|
+
personality: string
|
|
36
|
+
runtime: string
|
|
37
|
+
/** The runtime's own stated reset instant. */
|
|
38
|
+
resetsAtMs: number
|
|
39
|
+
/** The limit event's own time (transcript timestamp / codex completed_at). */
|
|
40
|
+
eventAtMs: number
|
|
41
|
+
/** The session file the event was read from — the progress probe re-reads it. */
|
|
42
|
+
file?: string
|
|
43
|
+
sessionId?: string
|
|
44
|
+
errorType?: string
|
|
45
|
+
attempts?: number
|
|
46
|
+
lastAttemptMs?: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const DIR = 'limit-parks'
|
|
50
|
+
|
|
51
|
+
function parkPath(cfg: LifecycleConfig, identity: string): string {
|
|
52
|
+
return join(cfg.stateDir, DIR, `${identity}.json`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function readLimitPark(cfg: LifecycleConfig, identity: string): LimitPark | null {
|
|
56
|
+
try {
|
|
57
|
+
const p = JSON.parse(readFileSync(parkPath(cfg, identity), 'utf8')) as LimitPark
|
|
58
|
+
return typeof p.resetsAtMs === 'number' && typeof p.eventAtMs === 'number' ? p : null
|
|
59
|
+
} catch {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Record (or supersede) a park. Returns false when an equal-or-newer event is already recorded —
|
|
65
|
+
* the overlapping mute-watch window re-reads the same line every sweep. */
|
|
66
|
+
export function recordLimitPark(cfg: LifecycleConfig, park: LimitPark): boolean {
|
|
67
|
+
const cur = readLimitPark(cfg, park.identity)
|
|
68
|
+
if (cur && cur.eventAtMs >= park.eventAtMs) return false
|
|
69
|
+
mkdirSync(join(cfg.stateDir, DIR), { recursive: true, mode: 0o700 })
|
|
70
|
+
writeFileAtomic(parkPath(cfg, park.identity), `${JSON.stringify({ ...park, attempts: 0 })}\n`)
|
|
71
|
+
return true
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function updateLimitPark(cfg: LifecycleConfig, park: LimitPark): void {
|
|
75
|
+
mkdirSync(join(cfg.stateDir, DIR), { recursive: true, mode: 0o700 })
|
|
76
|
+
writeFileAtomic(parkPath(cfg, park.identity), `${JSON.stringify(park)}\n`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function clearLimitPark(cfg: LifecycleConfig, identity: string): void {
|
|
80
|
+
try {
|
|
81
|
+
rmSync(parkPath(cfg, identity), { force: true })
|
|
82
|
+
} catch {
|
|
83
|
+
/* already gone */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function listLimitParks(cfg: LifecycleConfig): LimitPark[] {
|
|
88
|
+
const dir = join(cfg.stateDir, DIR)
|
|
89
|
+
if (!existsSync(dir)) return []
|
|
90
|
+
const out: LimitPark[] = []
|
|
91
|
+
for (const name of readdirSync(dir)) {
|
|
92
|
+
if (!name.endsWith('.json')) continue
|
|
93
|
+
const p = readLimitPark(cfg, name.slice(0, -'.json'.length))
|
|
94
|
+
if (p) out.push(p)
|
|
95
|
+
}
|
|
96
|
+
return out
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** True while superviseTick must NOT idle-reap this identity: a live park whose reset (plus grace)
|
|
100
|
+
* has not arrived yet and whose hold is within the bound. */
|
|
101
|
+
export function limitHoldActive(cfg: LifecycleConfig, identity: string, nowMs: number): LimitPark | null {
|
|
102
|
+
const p = readLimitPark(cfg, identity)
|
|
103
|
+
if (!p) return null
|
|
104
|
+
if (nowMs >= p.resetsAtMs + LIMIT_RESUME_GRACE_MS) return null
|
|
105
|
+
if (p.resetsAtMs - p.eventAtMs > LIMIT_MAX_HOLD_MS) return null
|
|
106
|
+
return p
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
110
|
+
// Progress probe — did the session do real work AFTER the limit event?
|
|
111
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
/** Claude: a REAL assistant reply (model ≠ "<synthetic>", which is the refusal itself) stamped after
|
|
114
|
+
* the event. A new limit refusal is not progress — it supersedes the park instead. */
|
|
115
|
+
export function claudeProgressedSince(text: string, eventAtMs: number): boolean {
|
|
116
|
+
for (const line of text.split('\n')) {
|
|
117
|
+
const s = line.trim()
|
|
118
|
+
if (!s.startsWith('{') || !s.includes('"assistant"')) continue
|
|
119
|
+
let d: { type?: unknown; timestamp?: unknown; isApiErrorMessage?: unknown; message?: { model?: unknown } }
|
|
120
|
+
try {
|
|
121
|
+
d = JSON.parse(s)
|
|
122
|
+
} catch {
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
if (d.type !== 'assistant' || d.isApiErrorMessage === true) continue
|
|
126
|
+
const model = d.message?.model
|
|
127
|
+
if (typeof model !== 'string' || !model || model === '<synthetic>') continue
|
|
128
|
+
const at = typeof d.timestamp === 'string' ? Date.parse(d.timestamp) : NaN
|
|
129
|
+
if (Number.isFinite(at) && at > eventAtMs) return true
|
|
130
|
+
}
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Codex: a turn that COMPLETED with a message after the event. Keyed on completed_at (replay-safe:
|
|
135
|
+
* a resumed rollout re-stamps line timestamps but keeps completed_at — see mutewatch.ts). */
|
|
136
|
+
export function codexProgressedSince(text: string, eventAtMs: number): boolean {
|
|
137
|
+
for (const line of text.split('\n')) {
|
|
138
|
+
const s = line.trim()
|
|
139
|
+
if (!s.startsWith('{') || !s.includes('task_complete')) continue
|
|
140
|
+
let d: { payload?: { type?: unknown; last_agent_message?: unknown; completed_at?: unknown } }
|
|
141
|
+
try {
|
|
142
|
+
d = JSON.parse(s)
|
|
143
|
+
} catch {
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
const p = d.payload
|
|
147
|
+
if (!p || p.type !== 'task_complete') continue
|
|
148
|
+
if (typeof p.last_agent_message !== 'string' || !p.last_agent_message) continue
|
|
149
|
+
if (typeof p.completed_at === 'number' && p.completed_at * 1000 > eventAtMs) return true
|
|
150
|
+
}
|
|
151
|
+
return false
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Did the parked session progress since its limit event? Unknown (no file / unreadable) → false:
|
|
155
|
+
* the nudge is cheap and a missed continuation is exactly the defect this exists for. */
|
|
156
|
+
export function parkProgressed(park: LimitPark): boolean {
|
|
157
|
+
if (!park.file) return false
|
|
158
|
+
const t = readSessionSlices(park.file, 512 * 1024)
|
|
159
|
+
if (!t) return false
|
|
160
|
+
if (park.runtime === 'claude') return claudeProgressedSince(t.tail, park.eventAtMs)
|
|
161
|
+
if (park.runtime === 'codex') return codexProgressedSince(t.tail, park.eventAtMs)
|
|
162
|
+
return false
|
|
163
|
+
}
|
|
@@ -320,6 +320,10 @@ async function server(input) {
|
|
|
320
320
|
}
|
|
321
321
|
async function selfCheck() {
|
|
322
322
|
const warnings = []
|
|
323
|
+
try {
|
|
324
|
+
const fromCore = JSON.parse(env.IAPEER_OPENCODE_WARNINGS || '[]') // launch-time facts (e.g. implicit provider)
|
|
325
|
+
if (Array.isArray(fromCore)) for (const w of fromCore) if (typeof w === 'string') warnings.push(w)
|
|
326
|
+
} catch {}
|
|
323
327
|
try {
|
|
324
328
|
const st = await client.mcp.status()
|
|
325
329
|
for (const [name, s] of Object.entries((st && st.data) || {})) {
|
package/src/registry/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
MAX_DESCRIPTION_LEN,
|
|
19
19
|
PEERS_PROFILES_FILE,
|
|
20
20
|
PEERS_SCHEMA_VERSION,
|
|
21
|
+
SYSTEM_PERSONALITY,
|
|
21
22
|
defaultIntelligenceForRuntime,
|
|
22
23
|
isIntelligence,
|
|
23
24
|
isRuntime,
|
|
@@ -120,6 +121,9 @@ function ensurePersonality(personality: string): void {
|
|
|
120
121
|
`invalid personality "${personality}" — must match /^[a-z][a-z0-9-]{0,31}$/`,
|
|
121
122
|
)
|
|
122
123
|
}
|
|
124
|
+
if (personality === SYSTEM_PERSONALITY) {
|
|
125
|
+
throw new IapError(`personality "${personality}" is reserved for the iapeer core's own sender identity`)
|
|
126
|
+
}
|
|
123
127
|
}
|
|
124
128
|
|
|
125
129
|
function ensureRuntime(runtime: string): Runtime {
|