@agfpd/iapeer 0.4.126 → 0.4.128
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 +44 -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 +101 -0
- package/src/daemon/main.ts +40 -41
- package/src/daemon/mutewatch.ts +31 -6
- package/src/identity/index.ts +28 -0
- package/src/lifecycle/index.ts +29 -0
- package/src/lifecycle/limitpark.ts +165 -0
- package/src/registry/index.ts +4 -0
- package/src/transport/index.ts +6 -1
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,42 @@ 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: the nudge carries an explicit continue-work
|
|
460
|
+
wake intent that satisfies the executor topic gate by itself (wake cause
|
|
461
|
+
`idle-reaped-continue`); it never outranks an owner `/new`, a soft-reload or a crash. Without
|
|
462
|
+
it the first live run (0.4.127) woke the peer FRESH (`idle-reaped-new-topic`) and the peer
|
|
463
|
+
answered «nothing to resume» — the very loss this section exists to prevent. Progress
|
|
464
|
+
already made → the park is dropped without a message (no duplicate turn). A failed delivery is
|
|
465
|
+
retried (5 min backoff, 3 attempts); an operator `stop` is never fought.
|
|
466
|
+
4. **Sender** = the core's system identity `iapeer-system` (runtime `core`, intelligence
|
|
467
|
+
`absent`): synthesized, never registered, the name reserved by the registry — so it exists on
|
|
468
|
+
every install and is never the target (the target may be the peer named `iapeer` itself). The
|
|
469
|
+
same identity now signs the OC07 interrupted/undelivered notices and the composer-queue
|
|
470
|
+
failure notices.
|
|
471
|
+
|
|
472
|
+
Every decision is in `lifecycle.log`: `ev=limit-park`, `outcome=limit-hold`, `ev=limit-resume
|
|
473
|
+
outcome=nudged|progressed|retry|gave-up|dropped`.
|
|
474
|
+
|
|
475
|
+
**Not covered:** opencode (its `session.error` states no reset) and a claude per-model bucket
|
|
476
|
+
(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,101 @@
|
|
|
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
|
+
// Topic = the stopped session's own thread (captured at park time), so the topic set still
|
|
78
|
+
// follows the work; the RESUME itself rides the explicit continue-work intent, not the topic.
|
|
79
|
+
const topic = p.topic || 'limit-resume'
|
|
80
|
+
const r = await send(systemCaller(), { personality: p.personality, runtime: p.runtime, message: limitResumeMessage(p), topic }, { ...routeDeps, wakeContinueWork: true })
|
|
81
|
+
if (r.ok) {
|
|
82
|
+
clearLimitPark(cfg, p.identity)
|
|
83
|
+
log({ identity: p.identity, outcome: 'nudged', woke: String(r.value.woke), resets: new Date(p.resetsAtMs).toISOString() })
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
reason = r.error.message
|
|
87
|
+
} catch (e) {
|
|
88
|
+
reason = e instanceof Error ? e.message : String(e)
|
|
89
|
+
}
|
|
90
|
+
const attempts = (p.attempts ?? 0) + 1
|
|
91
|
+
// A deliberate operator halt (stopped) is not a transient — never fight it.
|
|
92
|
+
const permanent = /\bstopped\b|not in the iapeer peers index/.test(reason)
|
|
93
|
+
if (permanent || attempts >= LIMIT_MAX_NUDGE_ATTEMPTS) {
|
|
94
|
+
clearLimitPark(cfg, p.identity)
|
|
95
|
+
log({ identity: p.identity, outcome: permanent ? 'dropped' : 'gave-up', attempts, err: reason.slice(0, 300) })
|
|
96
|
+
} else {
|
|
97
|
+
updateLimitPark(cfg, { ...p, attempts, lastAttemptMs: now })
|
|
98
|
+
log({ identity: p.identity, outcome: 'retry', attempts, err: reason.slice(0, 300) })
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
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'
|
|
@@ -138,7 +140,7 @@ const DAEMON_MAIN_PATH = fileURLToPath(import.meta.url)
|
|
|
138
140
|
export function makeWakeFn(cfg: LifecycleConfig, env: NodeJS.ProcessEnv): WakeFn {
|
|
139
141
|
return (req: WakeRequest): Promise<WakeOutcome> =>
|
|
140
142
|
wakeOrSpawn(
|
|
141
|
-
{ personality: req.personality, runtime: req.runtime, topic: req.topic, task: req.task },
|
|
143
|
+
{ personality: req.personality, runtime: req.runtime, topic: req.topic, task: req.task, ...(req.continueWork ? { continueWork: true } : {}) },
|
|
142
144
|
{ cfg, env },
|
|
143
145
|
)
|
|
144
146
|
}
|
|
@@ -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,24 @@ 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
|
+
...(readTopic(cfg, identity) ? { topic: readTopic(cfg, identity) } : {}),
|
|
497
|
+
})
|
|
498
|
+
if (fresh) appendLifecycleEvent(cfg.eventLogDir, { ev: 'limit-park', identity, resets: new Date(d.resetsAtMs).toISOString(), error_type: d.errorType }, { env })
|
|
499
|
+
},
|
|
511
500
|
})
|
|
512
501
|
// Goal-watch (docs/19) — the SAME board and the same class as the mute-watch: a peer left
|
|
513
502
|
// alive and green while the thing it was told to do quietly stopped. Separate detector
|
|
@@ -627,6 +616,16 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
|
|
|
627
616
|
noteInflight,
|
|
628
617
|
isLaunchdManaged: (personality: string) => isLaunchdManaged(personality, env),
|
|
629
618
|
})
|
|
619
|
+
// Limit-resume (docs/19 §9): a peer stopped by a model limit continues after the reset.
|
|
620
|
+
await processLimitResumes(cfg, env, {
|
|
621
|
+
env,
|
|
622
|
+
wake: makeWakeFn(cfg, env),
|
|
623
|
+
ephemeral,
|
|
624
|
+
noteLiveTopic,
|
|
625
|
+
noteDelivered: identity => setLastDelivered(cfg, identity),
|
|
626
|
+
noteInflight,
|
|
627
|
+
isLaunchdManaged: (personality: string) => isLaunchdManaged(personality, env),
|
|
628
|
+
})
|
|
630
629
|
},
|
|
631
630
|
// Surface a supervise-tick throw that the daemon timer would otherwise SWALLOW (the class that
|
|
632
631
|
// 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
|
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'
|
|
@@ -652,6 +653,10 @@ export function resolveWakeMode(
|
|
|
652
653
|
* next resume/fresh decision (a crash-looping binary used to eat .idle-reaped, and after the fix the
|
|
653
654
|
* peer came back FRESH, its context lost). Absent → consume immediately (legacy/test callers). */
|
|
654
655
|
defer?: (consume: () => void) => void,
|
|
656
|
+
/** The waking message is an explicit "continue THIS work" (the limit-resume nudge, docs/19 §9):
|
|
657
|
+
* in the idle-reaped executor branch it satisfies the topic gate by itself. Never outranks an
|
|
658
|
+
* owner's /new, a soft-reload, a folder-launch or a crash (those decide before 3b). */
|
|
659
|
+
continueWork?: boolean,
|
|
655
660
|
): WakeMode {
|
|
656
661
|
const done = defer ?? ((f: () => void) => f())
|
|
657
662
|
// 1. folder-launch → always fresh. Consume a standing .new-eager too — the mark's
|
|
@@ -724,6 +729,12 @@ export function resolveWakeMode(
|
|
|
724
729
|
// gap is not a request to resume specific work; defaulting it to resume revived stale
|
|
725
730
|
// context (e.g. a 3-day-old session). The sender opts INTO continuity by passing the topic.
|
|
726
731
|
const topic = incomingTopic?.trim() ?? ''
|
|
732
|
+
if (continueWork) {
|
|
733
|
+
const r = resolveResume(cwd)
|
|
734
|
+
return r.ok
|
|
735
|
+
? { resume: true, resumeRef: r.ref, cause: 'idle-reaped-continue' }
|
|
736
|
+
: { resume: false, cause: 'idle-reaped-no-resume' }
|
|
737
|
+
}
|
|
727
738
|
if (!topic) return { resume: false, cause: 'idle-reaped-no-topic' }
|
|
728
739
|
// RESUME if the incoming topic matches ANY thread this session lineage worked on — not
|
|
729
740
|
// just the last. Different threads accumulate in one session; a later ping on an earlier
|
|
@@ -966,6 +977,8 @@ export interface WakeArgs {
|
|
|
966
977
|
/** First message delivered to the woken session (the routed envelope). */
|
|
967
978
|
task: string
|
|
968
979
|
resume?: boolean
|
|
980
|
+
/** Explicit "continue THIS work" intent — see resolveWakeMode `continueWork`. */
|
|
981
|
+
continueWork?: boolean
|
|
969
982
|
}
|
|
970
983
|
|
|
971
984
|
export interface WakeResult {
|
|
@@ -1123,6 +1136,7 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
|
|
|
1123
1136
|
c => adapter.resolveResume(c, { personality: args.personality, env }),
|
|
1124
1137
|
args.topic,
|
|
1125
1138
|
f => consumeOnUp.push(f),
|
|
1139
|
+
args.continueWork,
|
|
1126
1140
|
)
|
|
1127
1141
|
const applyConsumed = (): void => {
|
|
1128
1142
|
for (const f of consumeOnUp.splice(0)) {
|
|
@@ -1520,6 +1534,18 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
|
|
|
1520
1534
|
}
|
|
1521
1535
|
}
|
|
1522
1536
|
if (ageSecs > cfg.idleSecs) {
|
|
1537
|
+
// LIMIT HOLD (docs/19 §9) — a session stopped by an exhausted model limit that STATED its reset is
|
|
1538
|
+
// not idle, it is waiting: keep it (warm context; the runtime's own continuation gets to fire) until
|
|
1539
|
+
// the reset + grace. Bounded in limitpark.ts; past the bound the reap proceeds and the limit-resume
|
|
1540
|
+
// nudge wakes it (an idle reap is resume-eligible) — either way the turn is not lost.
|
|
1541
|
+
const held = limitHoldActive(cfg, s.identity, nowMs)
|
|
1542
|
+
if (held) {
|
|
1543
|
+
// Traced ONCE per park (the tick runs every minute; a 3 h hold would flood lifecycle.log).
|
|
1544
|
+
const key = `${s.identity}@${held.eventAtMs}`
|
|
1545
|
+
if (verbose || !limitHoldTraced.has(key)) trace({ identity: s.identity, action: 'alive', age: `${ageSecs}s`, outcome: 'limit-hold', resets: new Date(held.resetsAtMs).toISOString() })
|
|
1546
|
+
limitHoldTraced.add(key)
|
|
1547
|
+
return { identity: s.identity, action: 'alive', reason: `limit hold until ${new Date(held.resetsAtMs).toISOString()}` }
|
|
1548
|
+
}
|
|
1523
1549
|
// THE ONLY place .idle-reaped is written: this is the one death the daemon
|
|
1524
1550
|
// INITIATES. Its presence on the next wake = the session was parked cleanly =
|
|
1525
1551
|
// RESUME-eligible (resolveWakeMode branch 3b). A crash/self-close (the dead
|
|
@@ -1536,6 +1562,9 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
|
|
|
1536
1562
|
return { identity: s.identity, action: 'alive' }
|
|
1537
1563
|
}
|
|
1538
1564
|
|
|
1565
|
+
/** Parks whose hold was already traced (process-local; a daemon restart re-traces once — harmless). */
|
|
1566
|
+
const limitHoldTraced = new Set<string>()
|
|
1567
|
+
|
|
1539
1568
|
export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): SuperviseOutcome[] {
|
|
1540
1569
|
const env = deps.env ?? process.env
|
|
1541
1570
|
const nowMs = deps.nowMs ?? Date.now()
|
|
@@ -0,0 +1,165 @@
|
|
|
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
|
+
/** The session's most-recent topic when parked — the nudge continues that thread. */
|
|
46
|
+
topic?: string
|
|
47
|
+
attempts?: number
|
|
48
|
+
lastAttemptMs?: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const DIR = 'limit-parks'
|
|
52
|
+
|
|
53
|
+
function parkPath(cfg: LifecycleConfig, identity: string): string {
|
|
54
|
+
return join(cfg.stateDir, DIR, `${identity}.json`)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function readLimitPark(cfg: LifecycleConfig, identity: string): LimitPark | null {
|
|
58
|
+
try {
|
|
59
|
+
const p = JSON.parse(readFileSync(parkPath(cfg, identity), 'utf8')) as LimitPark
|
|
60
|
+
return typeof p.resetsAtMs === 'number' && typeof p.eventAtMs === 'number' ? p : null
|
|
61
|
+
} catch {
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Record (or supersede) a park. Returns false when an equal-or-newer event is already recorded —
|
|
67
|
+
* the overlapping mute-watch window re-reads the same line every sweep. */
|
|
68
|
+
export function recordLimitPark(cfg: LifecycleConfig, park: LimitPark): boolean {
|
|
69
|
+
const cur = readLimitPark(cfg, park.identity)
|
|
70
|
+
if (cur && cur.eventAtMs >= park.eventAtMs) return false
|
|
71
|
+
mkdirSync(join(cfg.stateDir, DIR), { recursive: true, mode: 0o700 })
|
|
72
|
+
writeFileAtomic(parkPath(cfg, park.identity), `${JSON.stringify({ ...park, attempts: 0 })}\n`)
|
|
73
|
+
return true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function updateLimitPark(cfg: LifecycleConfig, park: LimitPark): void {
|
|
77
|
+
mkdirSync(join(cfg.stateDir, DIR), { recursive: true, mode: 0o700 })
|
|
78
|
+
writeFileAtomic(parkPath(cfg, park.identity), `${JSON.stringify(park)}\n`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function clearLimitPark(cfg: LifecycleConfig, identity: string): void {
|
|
82
|
+
try {
|
|
83
|
+
rmSync(parkPath(cfg, identity), { force: true })
|
|
84
|
+
} catch {
|
|
85
|
+
/* already gone */
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function listLimitParks(cfg: LifecycleConfig): LimitPark[] {
|
|
90
|
+
const dir = join(cfg.stateDir, DIR)
|
|
91
|
+
if (!existsSync(dir)) return []
|
|
92
|
+
const out: LimitPark[] = []
|
|
93
|
+
for (const name of readdirSync(dir)) {
|
|
94
|
+
if (!name.endsWith('.json')) continue
|
|
95
|
+
const p = readLimitPark(cfg, name.slice(0, -'.json'.length))
|
|
96
|
+
if (p) out.push(p)
|
|
97
|
+
}
|
|
98
|
+
return out
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** True while superviseTick must NOT idle-reap this identity: a live park whose reset (plus grace)
|
|
102
|
+
* has not arrived yet and whose hold is within the bound. */
|
|
103
|
+
export function limitHoldActive(cfg: LifecycleConfig, identity: string, nowMs: number): LimitPark | null {
|
|
104
|
+
const p = readLimitPark(cfg, identity)
|
|
105
|
+
if (!p) return null
|
|
106
|
+
if (nowMs >= p.resetsAtMs + LIMIT_RESUME_GRACE_MS) return null
|
|
107
|
+
if (p.resetsAtMs - p.eventAtMs > LIMIT_MAX_HOLD_MS) return null
|
|
108
|
+
return p
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
112
|
+
// Progress probe — did the session do real work AFTER the limit event?
|
|
113
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
/** Claude: a REAL assistant reply (model ≠ "<synthetic>", which is the refusal itself) stamped after
|
|
116
|
+
* the event. A new limit refusal is not progress — it supersedes the park instead. */
|
|
117
|
+
export function claudeProgressedSince(text: string, eventAtMs: number): boolean {
|
|
118
|
+
for (const line of text.split('\n')) {
|
|
119
|
+
const s = line.trim()
|
|
120
|
+
if (!s.startsWith('{') || !s.includes('"assistant"')) continue
|
|
121
|
+
let d: { type?: unknown; timestamp?: unknown; isApiErrorMessage?: unknown; message?: { model?: unknown } }
|
|
122
|
+
try {
|
|
123
|
+
d = JSON.parse(s)
|
|
124
|
+
} catch {
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
if (d.type !== 'assistant' || d.isApiErrorMessage === true) continue
|
|
128
|
+
const model = d.message?.model
|
|
129
|
+
if (typeof model !== 'string' || !model || model === '<synthetic>') continue
|
|
130
|
+
const at = typeof d.timestamp === 'string' ? Date.parse(d.timestamp) : NaN
|
|
131
|
+
if (Number.isFinite(at) && at > eventAtMs) return true
|
|
132
|
+
}
|
|
133
|
+
return false
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Codex: a turn that COMPLETED with a message after the event. Keyed on completed_at (replay-safe:
|
|
137
|
+
* a resumed rollout re-stamps line timestamps but keeps completed_at — see mutewatch.ts). */
|
|
138
|
+
export function codexProgressedSince(text: string, eventAtMs: number): boolean {
|
|
139
|
+
for (const line of text.split('\n')) {
|
|
140
|
+
const s = line.trim()
|
|
141
|
+
if (!s.startsWith('{') || !s.includes('task_complete')) continue
|
|
142
|
+
let d: { payload?: { type?: unknown; last_agent_message?: unknown; completed_at?: unknown } }
|
|
143
|
+
try {
|
|
144
|
+
d = JSON.parse(s)
|
|
145
|
+
} catch {
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
const p = d.payload
|
|
149
|
+
if (!p || p.type !== 'task_complete') continue
|
|
150
|
+
if (typeof p.last_agent_message !== 'string' || !p.last_agent_message) continue
|
|
151
|
+
if (typeof p.completed_at === 'number' && p.completed_at * 1000 > eventAtMs) return true
|
|
152
|
+
}
|
|
153
|
+
return false
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Did the parked session progress since its limit event? Unknown (no file / unreadable) → false:
|
|
157
|
+
* the nudge is cheap and a missed continuation is exactly the defect this exists for. */
|
|
158
|
+
export function parkProgressed(park: LimitPark): boolean {
|
|
159
|
+
if (!park.file) return false
|
|
160
|
+
const t = readSessionSlices(park.file, 512 * 1024)
|
|
161
|
+
if (!t) return false
|
|
162
|
+
if (park.runtime === 'claude') return claudeProgressedSince(t.tail, park.eventAtMs)
|
|
163
|
+
if (park.runtime === 'codex') return codexProgressedSince(t.tail, park.eventAtMs)
|
|
164
|
+
return false
|
|
165
|
+
}
|
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 {
|
package/src/transport/index.ts
CHANGED
|
@@ -1272,6 +1272,8 @@ export interface WakeRequest {
|
|
|
1272
1272
|
topic?: string
|
|
1273
1273
|
/** First message delivered to the woken session (the routed envelope). */
|
|
1274
1274
|
task: string
|
|
1275
|
+
/** Explicit "continue THIS work" intent (core-originated limit-resume only). */
|
|
1276
|
+
continueWork?: boolean
|
|
1275
1277
|
}
|
|
1276
1278
|
export interface WakeOutcome {
|
|
1277
1279
|
status: 'READY' | 'FAILED'
|
|
@@ -1347,6 +1349,9 @@ export interface ComposerQueueRouteDeps {
|
|
|
1347
1349
|
}
|
|
1348
1350
|
|
|
1349
1351
|
export interface RouteDeps {
|
|
1352
|
+
/** A wake triggered by THIS send carries the "continue THIS work" intent (resume an idle-reaped
|
|
1353
|
+
* session regardless of topic). Set only by the core's limit-resume pass — never from a request. */
|
|
1354
|
+
wakeContinueWork?: boolean
|
|
1350
1355
|
/** Б7 — injected env sandboxes ALL of routeSend's env-resolved reads (the registry, the supervisor
|
|
1351
1356
|
* run-dir liveness/delivery). Default process.env (prod: env === process.env). Closes the daemon:286
|
|
1352
1357
|
* isolation invariant — a library/test daemon with an injected root no longer reads the real registry
|
|
@@ -1970,7 +1975,7 @@ async function routeSendInner(
|
|
|
1970
1975
|
`(restart window outlasted the grace?) — message NOT delivered; retry`,
|
|
1971
1976
|
)
|
|
1972
1977
|
}
|
|
1973
|
-
const woke = await deps.wake({ personality, runtime, topic, task: envelope })
|
|
1978
|
+
const woke = await deps.wake({ personality, runtime, topic, task: envelope, ...(deps.wakeContinueWork ? { continueWork: true } : {}) })
|
|
1974
1979
|
if (woke.status === 'FAILED') {
|
|
1975
1980
|
// C1 — a durably STOPPED peer is a deliberate halt, not a transient miss:
|
|
1976
1981
|
// surface the explicit "stopped" reason (no "offline and wake failed" wrapping).
|