Skip to content

feat(runtime): add plugin composition foundation - #3250

Open
xxhZs wants to merge 1 commit into
apache:mainfrom
xxhZs:feat/extension-kernel-migration-plan
Open

feat(runtime): add plugin composition foundation#3250
xxhZs wants to merge 1 commit into
apache:mainfrom
xxhZs:feat/extension-kernel-migration-plan

Conversation

@xxhZs

@xxhZs xxhZs commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Positioning

This PR lands the Runtime foundation extracted from feat/extension-hook-contributions:

  • the Context / Fiber / Service kernel and Effect ownership;
  • plugin runtime contracts for packages, composition entries, snapshots, transactions, and inspection;
  • MakaCompositionLoader for activation, tree mutation, transaction staging, package-wide reload, switching, and retirement;
  • the three feat-defined @maka/runtime subpath exports.

This is intentionally a foundation PR, not an independently usable product or Runtime Host integration. It does not add a Host Controller, durable Store, persistence, trusted package loader, product installation surface, Tool/UI wiring, or default consumer.

A later Host/product slice should migrate the feat-defined HostExtensionController, HostExtensionRuntime, and HostPluginCompositionStore instead of introducing a parallel adapter or persistence model.

Scope boundary

The diff remains limited to six Runtime files: three foundation modules, two focused test files, and Runtime package exports. No Host, Storage, or product package is changed. No public product model, persistence contract, or Provider transaction model is introduced. Entry config/inject/intercept values retain the feat shallow-copy contract.

Regression-tested foundation fixes

The implementation repairs foundation behavior without adding a new capability:

  1. Service activation and coalesced dependency refresh;
  2. Fiber waiting across queued transitions, serialized config update/rollback, and preservation of raw config across Standard Schema validation;
  3. terminal Fiber and Effect disposal tasks shared by concurrent callers, with complete LIFO cleanup;
  4. Effect-owned event hooks and contribution registration, including unloading and committed-transaction fences;
  5. no child Fiber escape from an unloading Context;
  6. weak Plugin-identity Runtime caching and Proxy-safe Fiber getters;
  7. safe Service/isolation/intercept records for names such as constructor and toString;
  8. accessor collision rejection for existing Context properties;
  9. disabled-tree projection and preservation of live descendants during structural updates;
  10. failed rebind and failed insert operations leave parent/position/root snapshots unchanged;
  11. callable config inspection and callable intercept structural comparison preserve live/snapshot consistency;
  12. replacement subtree identity checks and lossless special session IDs;
  13. read-only tree inspection with complete package dependency and Fiber failure diagnostics;
  14. transaction cleanup, publish boundaries, parallel dispatch, and bounded Service-label behavior covered by focused regressions.

Explicitly deferred limitations

These require lifecycle or transaction contracts beyond this migration PR and are not claimed as fixed:

  • atomic replacement and rollback for Service-providing entries across apply(), replaceSnapshot(), structural update, package reload, and move/rebind;
  • direct candidate Context Effects becoming visible before Tree publication;
  • the state transition policy for asynchronous Effects that fail after Fiber activation;
  • Service Proxy support for ECMAScript private fields;
  • Host-layer generation, close/install, typed-operation, and persistence policy.

Validation

  • Plugin kernel and composition loader: 53/53 focused tests passing.
  • Runtime Host full suite: 1019/1019 passing.
  • Runtime and Runtime Host typecheck and build: passing.
  • Focused Biome check and git diff --check: passing.
  • Windows test skip inventory: current, 62 declarations.
  • Full Runtime local run: 3007 passing, 13 skipped; the same 9 failures remain confined to model-factory-tool-call-index and node-pty-write-lifecycle, outside this PR six-file diff.

Generative tooling disclosure

OpenAI Codex made a substantive contribution to implementation, tests, review analysis, and PR wording. The commit includes a truthful Generated-by: Codex trailer.

@me2seeks

me2seeks commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Nice direction overall. Splitting the lifecycle kernel from Tool/UI/Event makes the extension work much easier to review and evolve. Entry as the sole durable composition authority, with Fiber as its process-local projection, is a clean boundary.

Before treating K0–K4 as stable, I think two invariants need tightening:

  1. Cleanup must remain exhaustive when a disposer fails.

    KernelExtensionContext.dispose() marks the context as disposed before running the disposer loop. If one disposer throws, the remaining disposers are skipped, while subsequent dispose() calls return immediately. During retirement the active map has already switched and the error is only reported through onDiagnostic, so the remaining effects can become untracked.

    Please add a test with multiple effects where a middle disposer fails, and ensure every disposer is attempted exactly once with failures aggregated/reported. If cleanup still cannot be proven complete, the Fiber should remain observable as draining/poisoned or explicitly require a Host restart.

  2. Package identity must be immutable across recovery.

    An Entry persists only packageId, while FileExtensionPackageLoader resolves that ID to a path on each activation. Unless the package registry guarantees an immutable packageId → bytes mapping, the same durable Entry snapshot can load different code after a restart.

    packageId could itself be content-addressed, or the Entry could include an immutable revision/digest. This does not require restoring a second composition authority. Run admission should eventually pin the exact admitted composition as well.

One boundary is also worth making explicit: FileExtensionPackageLoader is a trusted in-process loader. It should not become the execution path for agent-authored or otherwise untrusted packages. The worker/capability/lease boundary and UI iframe/CSP isolation explored in #3003 are still needed above this kernel.

I do not consider the absence of Tool/UI/protocol wiring a defect in this PR—the narrow slice is a strength. But I would not call the foundation safely unloadable until the cleanup-failure case is handled, or deterministically recoverable until immutable package identity is documented and enforced.


中文摘要

整体方向赞成:Entry 作为唯一持久化组合权威、Fiber 作为进程内投影,且把 Kernel 与 Tool/UI/Event 分开是正确的。当前主要有三点:

  • disposer 抛错时仍应继续清理其他 effect,并聚合上报;否则旧副作用可能失去追踪。
  • packageId 必须稳定指向不可变 bytes/revision,否则同一 Entry 重启后可能加载不同代码;Run admission 也应固定确切 composition。
  • FileExtensionPackageLoader 只适用于可信的进程内扩展;Agent/用户提供的包仍需 worker/capability/lease,UI 仍需 iframe/CSP。

本 PR 没有接入 Tool/UI/protocol 是合理的窄切片,不是缺陷;但 cleanup failure 与不可变 package identity 应成为底座稳定前的门槛。

@xxhZs xxhZs changed the title feat(runtime-host): add extension composition kernel feat(runtime-host): add Entry/Fiber composition kernel (K0–K4) Aug 19, 2026
@me2seeks

me2seeks commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

One additional concern after looking specifically at the CAS and recovery semantics:

The Store-level CAS is a good no-lost-update mechanism: it locks, re-reads the current revision, validates expectedRevision, writes and fsyncs a temporary file, renames it, and fsyncs the directory. However, its guarantee currently stops at the Entry snapshot.

  1. CAS does not guarantee single Fiber activation.

    Two ExtensionKernel instances can recover the same healthy snapshot and both activate all Fibers without writing the Store, so no CAS conflict occurs. The Extension Kernel therefore still requires the Runtime Host’s exclusive root-owner lease as its single-writer/single-activation authority. It would be useful to make that integration invariant explicit and ensure the Store cannot be opened outside the authenticated owner lifecycle in production composition.

  2. A CAS loser has no reconciliation path.

    On a Store conflict, the candidate is disposed, but the Kernel retains its stale Entry snapshot and revision. Future mutations can continue to conflict. Transparently replaying mutate(callback) would not be safe because the callback may depend on the old base and staging may repeat effects. I think a conflict should fence/mark the Kernel unhealthy and require an explicit read-and-reconcile or Host restart.

  3. A durable commit can have an uncertain client outcome.

    If the Host crashes after the Entry snapshot is committed but before commitStage() or before the response reaches the Client, recovery correctly reconstructs the new Entry, but the caller cannot know whether its operation committed. The eventual Host protocol should carry at least expectedHostEpoch, expectedRevision, and a stable operationId, with reconnect/read-back semantics rather than blind retry.

  4. Candidate activation is not transactional with the Entry CAS.

    activate() runs before the durable write. A candidate can therefore create externally visible effects—network calls, child processes, global listeners, or remote state—even if the Entry CAS later fails. This needs either a prepare/publish activation boundary, capability-managed effects that remain unpublished until commit, or explicit idempotency for unavoidable external actions.

I would add Kernel-level tests—not only Store tests—for two complete Kernels competing, recovery under the Host owner lease, CAS-loser fencing/reconciliation, and crash injection immediately before and after the durable commit. In short: the current CAS proves no lost Entry update, but not yet single activation, deterministic conflict recovery, or uncertain-outcome reconciliation.


中文摘要

当前 Store CAS 能避免 Entry snapshot 丢更新,但还没有覆盖完整的 Extension authority 恢复语义:

  • CAS 不能防止两个 Kernel 同时恢复并激活相同 Fiber;生产接入必须受 Host 独占 root-owner lease 约束。
  • CAS loser 会保留旧 revision,不能安全透明重放 mutation;应 fence 当前 Kernel,再显式 reconcile 或重启。
  • durable commit 后如果响应丢失,Client 无法判断操作是否成功;协议需要 hostEpochexpectedRevision、稳定 operationId 和重连回读。
  • activate() 发生在 durable commit 之前,候选插件可能提前产生外部副作用;需要 prepare/publish、受控 capability 或外部幂等。

因此现有 CAS 证明的是“Entry 不丢更新”,还没有证明“Fiber 单实例、冲突可恢复、未知结果可对账”。建议增加完整 Kernel 竞争、owner lease、CAS loser 和 commit 前后 crash-injection 测试。

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for putting the Entry/Fiber kernel behind a clear authority boundary. The Store CAS, atomic persistence, stage-before-commit shape, and focused tests are a solid foundation, and this PR has no UI/UX screenshot requirement.

I cannot approve this exact head. I found four additional current-head issues and added them inline below. The existing public findings about exhaustive cleanup, immutable package identity, owner/lease fencing, CAS-loser cleanup, and activation before durable CAS also remain actionable; I have not duplicated those threads.

The PR body and all three commit bodies currently contain no AI-use statement or Generated-by: trailer. Please explicitly state whether generative tooling made a material contribution. If it did, disclose the tool and scope in the PR body and add truthful trailers to each materially assisted commit; if it did not, say that clearly in the body.

Several required workflows are still queued/running, so CI is not yet a merge-ready signal.

AI-assisted review disclosure: OpenAI Codex performed the exact-head lifecycle, authority, module-loading, reconciliation, provenance, and CI analysis; I verified each reproduction, severity, deduplication, and smallest-fix direction before posting.

中文说明

当前新增四个问题:dispose 与 mutation 的生命周期竞态;reload 只 cache-bust 入口而无法刷新静态依赖;Entry 可变引用绕过 authority;structural parent 改挂后不重建后代。已有公开评论中的 cleanup、package identity、lease/CAS fencing 等问题也仍有效,不重复开线程。

另外 PR body 和 3 个 commit 都没有 AI 使用说明。请明确是否有实质性 AI 辅助;若有,body 说明工具与范围,并给相应提交补 Generated-by:;若没有,也请在 body 明确说明。

}

async dispose(): Promise<void> {
await this.#mutationTail;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Serialize disposal with mutations for the full lifecycle transition

dispose() only awaits the mutation tail as it exists at entry. After fibers are cleared, #started remains true while disposers run, so a concurrent install() can successfully commit and activate a new fiber; dispose() then marks the kernel stopped. I reproduced durable and active entries containing the new fiber while every later mutation fails with “Extension kernel has not started,” leaving an unmanaged live effect. Please put recover/restart/dispose and mutations behind one lifecycle queue or explicit state machine, and add this interleaving regression.

中文说明

dispose 只等调用瞬间的 mutation tail;清空 Fiber 后到 disposer 结束前仍显示 started。此窗口 install 会成功激活新 Fiber,随后 dispose 又把 kernel 标成 stopped,留下无法再管理的活跃 effect。应让生命周期操作与 mutation 共用同一队列或状态机。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in . /// and every mutation now share one lifecycle queue. The new interleaving regression holds a disposer open, queues an install, and verifies the install is rejected after disposal without a durable or active Fiber leak.

const modulePath = await this.#resolvePath(entry.packageId);
const generation = (this.#generation += 1);
const moduleUrl = pathToFileURL(modulePath);
moduleUrl.searchParams.set('makaExtensionGeneration', String(generation));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Reload the extension's whole module graph, not only its entry URL

The generation query cache-busts only the entry module. Relative static imports such as ./dep.mjs keep the same URL and remain in Node's ESM cache. I reproduced changing dep.mjs, calling reload('one'), and receiving ["old", "old"]. Please load each immutable package revision from a new content-addressed/versioned directory so every module URL changes, or narrow/remove the same-path reload contract. Add a multi-file package regression.

中文说明

generation query 只改变入口 URL,静态相对依赖仍命中 Node ESM cache;修改 dep.mjs 后 reload 仍得到旧值。应让整个模块图位于新的不可变版本目录,或收窄当前 reload 契约,并补多文件扩展测试。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in . Package Entries now pin an immutable SHA-256 digest, and the trusted registry resolves each digest to a distinct immutable package directory. The entry URL no longer query-busts a mutable path. A two-version, multi-file static-import regression verifies then module graphs.

if (entries.some((candidate) => candidate.entryId === entry.entryId)) {
throw new Error(`Extension entry ${entry.entryId} already exists`);
}
entries.push(entry);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep mutable caller references outside the Entry authority

Install/reconfigure retain caller-owned objects, while the entries getter also returns the internal objects directly. Mutating the original entry after install can make the kernel report enabled: false and changed configuration while the fiber remains active and the durable snapshot still contains the old values. Canonically validate/clone JSON-compatible entry data at mutation boundaries and do not expose mutable internal references; add input- and getter-mutation regressions.

中文说明

当前既保留调用方传入对象,也直接暴露内部 Entry 对象。安装后修改原对象即可让内存视图、活跃 Fiber 与 durable snapshot 三者分裂。请在 authority 边界做规范化 clone/validation,并避免泄露内部可变引用。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in . Entry values are canonically validated/cloned after mutation callbacks and at recovery/getter/notification boundaries. The regression mutates both the original nested configuration and a getter projection and verifies kernel/store authority remains unchanged.

const forceReload = forceReloadIds.has(entry.entryId);
const parentChanged = entry.parentId ? changed.has(entry.parentId) : false;
const active = entry.enabled && !hasDisabledAncestor(next, entry);
if (!active || !entry.packageId) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Rebuild descendants when a structural ancestor is reparented

For an enabled Entry without packageId, this branch only records changed when it currently has a fiber or is force-reloaded. If structural group G moves from provider A to provider B, G's fingerprint change is not propagated, so child C is reused with A's injected context while the durable graph says its ancestor is B. I reproduced only one activation with provider A after the reparent. Mark changed structural entries from their fingerprint/parent change and rebuild affected descendants; add the A→B reparent regression.

中文说明

无 package 的结构节点改挂父节点时不会进入 changed,后代因此不重建:durable graph 已指向 B,但活跃 child 仍持有 A 的上下文。请传播结构节点的 fingerprint/parent 变化并补改挂回归。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in . Structural Entries now propagate their own fingerprint/parent changes through , even without a Fiber. The A→B structural reparent regression verifies the descendant is rebuilt and injects B after previously injecting A.

@xxhZs
xxhZs force-pushed the feat/extension-kernel-migration-plan branch from 6b0d4cf to 5ef2682 Compare August 20, 2026 03:55
@xxhZs

xxhZs commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the review findings in 5ef268220:

  • cleanup now attempts every Fiber/effect disposer exactly once and aggregates diagnostics;
  • package Entries pin immutable SHA-256 identities and registry versions use distinct directories for the complete module graph;
  • a mandatory Runtime Host root-owner authority is asserted for recovery and commits; complete-Kernel CAS losers are fenced until explicit restart reconciliation;
  • trusted loading now uses prepare → durable CAS → publish, so a CAS loser aborts without publishing candidate effects;
  • all lifecycle transitions and mutations share one queue;
  • Entry inputs/outputs are canonically cloned and JSON-validated;
  • structural reparent changes rebuild descendants.

Added regressions for each of those paths; targeted Kernel tests are 23/23. Typecheck, build, Biome, and diff check pass. The full local Runtime Host run passed 1027/1028; the unrelated owned-candidate timing test failed once in the parallel run and passed 6/6 immediately in isolation.

The PR body now states the trusted in-process/untrusted worker boundary, reserves uncertain-outcome reconciliation for future Host protocol wiring (hostEpoch/revision/operationId), and includes the required generative-tooling disclosure. All four materially assisted branch commits now carry Generated-by: Codex.

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for splitting this out as its own kernel — the Entry (durable) / Fiber (process-local projection) separation is the right shape, and I want to say up front that the queue serialization, the CAS revision discipline, the cycle rejection, and the depth-ordered publish/retire ordering all hold up under attack. The four inline findings from my earlier pass and me2seeks' two are genuinely fixed at this head, and I did not re-raise them.

Reviewed exact head 5ef2682201141d8282e792d47be30be21eaabf8c.

Two problems keep me from calling this a foundation to build on. First, both P1s below turn extension-controlled input into a kernel that can never start again: a durable diagnostic is written unbounded into a 1 MB-capped snapshot, and one throwing disposer aborts the whole recovery. Both were reproduced, both leave #state at 'stopped' so setEnabled/remove reject with "Extension kernel has not started", and both are one-line fixes. A kernel whose recovery is not fail-safe cannot be the thing that owns durable extension state.

Second, and this is the one I would settle before the follow-on Tool/UI/Hook PRs: the only public design record for this work is #2973, which the PR does not link, and whose central invariant is that each Agent Run uses an immutable composition snapshot that later changes must never mutate. This kernel has no Run dimension — one #fibers map mutated in place — so a reconfigure retires and rebuilds a composition under an in-flight Run. The branch briefly carried a plan doc that explicitly chose to drop the Revision/Binding/candidate model and then deleted it, so the deviation exists only in commit history. Either the invariant comes back or the deviation gets published on #2973; what should not happen is that it lands silently inside a PR titled "composition kernel".

Third, structurally: this is +1834/-0 with zero callers anywhere in the repo, and it adds twenty exports to @maka/runtime-host/server — a barrel whose six existing exports each have a real consumer — while the tests import by relative path. Unreachable public surface is un-versioned and un-reviewed against any consumer. I would land the entry store, the kernel, and the package loader as three PRs and add barrel exports in the PR that wires something to them.

Smaller items I am not filing inline: ExtensionFiber.generation is assigned by the loader and every test fake and read by nobody (it was the import cache-buster before the digest fix); lastDiagnostic.phase is copied unchecked by cloneEntry while message is validated, so a forged phase outside the 'recovery' | 'activation' union reaches the durable file; ExtensionKernelDiagnostic.entryId is required but change-notification passes ''; and inject/isolate/intercept are a four-knob injection algebra with no consumer and two different isolate semantics between inject() and resolveForChild(). Also, no CI checks have run on this head, so the body's "previous PR-head CI passed" is not a signal for it, and the branch is still named after the plan doc that was removed.

Review disclosure: this review was prepared with Claude Code running two independent reviewer passes — one on security and correctness/resource bounds, one on integration and simplification — whose findings I merged and deduplicated against the existing threads. The reproductions noted inline were executed by transpiling the kernel standalone; the file has no runtime imports, so that is faithful to this head. The human contributor reviewed the findings before posting.

} catch (error) {
await context.dispose();
if (!tolerateActivationFailure) throw new ExtensionActivationError(entry.entryId, error);
diagnostics.set(entry.entryId, error);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Bound the diagnostic before it becomes durable state. withDiagnostics copies error.message verbatim into the Entry and nothing truncates it, despite the body's "persists bounded diagnostics". Reproduced: a snapshot with one enabled entry whose prepare() throws new Error('x'.repeat(2 << 20)) makes recover() write a snapshot past the 1 MB cap in extension-entry-file-store.ts, which throws Extension Entry Tree snapshot exceeds the size limit, aborts the stage, and leaves #state at 'stopped' — so every later recover() fails identically and mutate/setEnabled/remove all reject with "Extension kernel has not started". The offending entry can then only be disabled by hand-editing the snapshot file. The mutation path escapes this by accident, because ExtensionActivationError re-wraps and the text lands in cause; recovery has no such wrapper. Truncate in withDiagnostics, and drop diagnostics rather than fail the durable write when the snapshot would exceed the cap; add a recovery test with an oversized activation message.

const fiber = await this.#loader.prepare(entry, context);
staged.set(entry.entryId, { fingerprint, fiber, context });
} catch (error) {
await context.dispose();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Route this cleanup through #disposeWithDiagnostic like every other disposal site. KernelExtensionContext.dispose() aggregates disposer errors and rethrows, so this bare await escapes before line 394 ever consults tolerateActivationFailure — the one branch whose entire purpose is to tolerate a failure. Reproduced: an entry whose prepare() registers a throwing effect and then throws makes recover() reject with AggregateError: Extension context ... cleanup failed instead of recording a recovery diagnostic; the kernel never reaches 'running' and setEnabled(id, false) then rejects with "Extension kernel has not started" — the same permanently-unstartable state as the finding above, from a different trigger. The same line also degrades the mutation path: the AggregateError replaces the ExtensionActivationError, so #mutate sees failedEntryId === undefined, persists no diagnostic, and still burns a revision writing an unchanged snapshot.

readonly #loader: ExtensionFiberLoader;
readonly #options: ExtensionKernelOptions;
#entries = new Map<string, ExtensionEntry>();
#fibers = new Map<string, ActiveFiber>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Decide in public whether a composition can be mutated under an in-flight Run. #2973 — the only public design record for this work, and not linked from this PR — states that each Agent Run uses an immutable Extension Composition snapshot and that changes produce a candidate for future work and must never mutate the composition of an active Run. This map is the whole live composition and #commitStage mutates it in place, so a reconfigure retires and rebuilds fibers underneath a Run that is still using them; grep for Run/Turn/snapshot in this file returns nothing. The branch briefly contained a plan doc that explicitly dropped the Revision/Binding/candidate model and was then deleted from the branch, so today the deviation is recorded only in commit history. Either pin compositions per Run and gate #commitStage on drain, or publish the deviation on #2973 and say how the invariant returns — because the follow-on Tool/UI/Hook PRs are being told to build on this shape.

for (const active of fibers) await this.#disposeWithDiagnostic(active, 'retirement');
}

#enqueue<T>(operation: () => Promise<T>): Promise<T> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Give the lifecycle queue a deadline and a cancellation path. Searching these three files for Abort, signal, timeout or setTimeout returns nothing: #stage awaits loader.prepare() unbounded, #commitStage awaits fiber.publish() unbounded, disposers are awaited unbounded, and waitForLock blocks with no deadline — while the repo's other lock helper, storage/src/file-update-lock.ts, does bound its wait. Because this single tail is the kernel's whole lifecycle authority, one badly-behaved package wedges everything behind it. Reproduced: a package whose prepare() returns a never-settling promise makes install() hang and then dispose(), enqueued behind it, never resolve — so any Host shutdown awaiting kernel.dispose() hangs with no way to abandon the in-flight stage. The cross-process variant is worse: another process holding the lock file blocks every mutation and dispose() with no deadline and no diagnostic. Pass an AbortSignal into ExtensionFiberLoader.prepare so cancellation reaches the awaited work, and bound the lock wait.

);
}

function cloneJsonRecord(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Strip the prototype keys at the boundary that advertises canonical validation. cloneJsonRecord is JSON.parse(JSON.stringify(value)) with no reviver, and assertJsonValue only inspects Object.getPrototypeOf(value), which is Object.prototype for a JSON-parsed object carrying an own __proto__ key. Reproduced: a configuration parsed from {"__proto__":{"polluted":"yes"},"constructor":{"prototype":{...}}} reaches the extension with those own property names intact and round-trips through the durable snapshot; a consumer doing Object.assign(target, configuration) then has target's prototype replaced (plain spread is safe, so the blast radius depends on the consumer). It is P2 only because no consumer exists yet — but this function is precisely the boundary the PR advertises as canonical, and the protocol that eventually feeds it client-supplied configuration is the reason it exists. Reject or drop __proto__ / constructor / prototype keys here.

this.#path = path;
}

async read(): Promise<ExtensionEntrySnapshot> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Check the size before the bytes are resident, and do not follow a symlink here. read() does await readFile(this.#path) and only then compares against MAX_SNAPSHOT_BYTES, so a 1 GB file is fully buffered before the cap rejects it — and write() calls read() while holding the lock, so the spike blocks every other writer too. The lock path a few lines below is hardened with O_NOFOLLOW and a dev/ino stability check; the snapshot path, which is the one carrying the durable state, gets neither. The repo already ships the correct primitive: readBoundedMarkerFile in packages/storage/src/marker-file.ts opens with O_NOFOLLOW|O_NONBLOCK, fstats for size and identity, and only then reads. Reuse it rather than replicating a weaker version.

`Extension Entry Tree revision must advance from ${expectedRevision} to ${expectedRevision + 1}`,
);
}
const temporary = `${this.#path}.${randomUUID()}.tmp`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Remove the temporary file on every failure path, not just a failed rename. rm(temporary, { force: true }) appears only in the rename catch, so a throw from handle.writeFile or handle.sync — ENOSPC, EIO, EDQUOT — closes the handle in the finally and leaves an orphaned <path>.<uuid>.tmp behind forever, with nothing to sweep it. The realistic case is the worst one: a caller retrying a mutation under a full disk creates one new orphan per attempt, consuming the space it is already short of. publishMarkerFile in the same repo gets this right with a tempCreated flag and an unlink in finally.

}
}

async function withEntryStoreLock<T>(lockPath: string, operation: () => Promise<T>): Promise<T> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Call the repository's advisory-lock helper instead of forking it. withEntryStoreLock and assertStableLockFile reproduce packages/storage/src/artifact-writer-lock.ts step for step — the same module-level gate map, the same gate-promise pattern, the same open, assert-stable, waitForLock, assert-stable, operate, unlock, close sequence, and the same dev/ino identity check — and the PR also forks storage's fs-native-extensions.d.ts with a narrower waitForLock signature and adds a second direct dependency on the native module. Two copies of the repo's locking protocol will drift, and a fix to one will not reach the other. The branch's own removed plan doc said this layer should reuse the existing storage/transaction boundary. Add the missing exports entry to @maka/storage and call the existing helper; the forked .d.ts and the duplicate dependency then delete themselves.

export { installRuntimeHostLogCapture } from '../process-diagnostics.js';
export {
ExtensionKernel,
type ExtensionContext,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Add the barrel exports in the PR that wires a consumer. The twenty names added here have no importer anywhere in apps/, packages/cli, packages/eval, or runtime-host's own composition, while the six exports that were already in this barrel each have a real one — and this PR's tests import by relative path, so removing the whole hunk breaks nothing. Public surface that nothing reaches is un-versioned and cannot be reviewed against a caller's needs, which is precisely when API shape mistakes get locked in.

const PACKAGE_DIGEST = `sha256:${'a'.repeat(64)}` as const;
const TEST_AUTHORITY = { assertCurrent() {} };

type LegacyFiber = Omit<ExtensionFiber, 'publish'>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Test the contract this PR ships, not a superseded shape. The LegacyFiber/LegacyLoader shim and the ExtensionKernel subclass convert activate into prepare, inject a no-op publish(), supply the authority, and back-fill packageDigest in both mutate() and MemoryStore.read() — so 20 of the 23 tests never exercise the prepare/publish activation boundary, the mandatory authority, or the packageId/packageDigest durable invariant; the scaffolding supplies all three. Since this PR introduces ExtensionFiberLoader, there is no legacy to stay compatible with: the shim is keeping a pre-hardening API alive so the existing tests did not have to be rewritten, which is the parallel path AGENTS.md asks us not to create. Relatedly, ExtensionKernelAuthority has no production implementer and no test in which assertCurrent() throws, so the single-activation guarantee is currently unverified — deleting the interface and its five call sites leaves all 23 tests green. The repo already has assertInteractiveRootOwner and ArtifactWriterLockAuthority.assertCurrentRoot for this; take one of them, and add a test where the authority rejects.

@xxhZs
xxhZs force-pushed the feat/extension-kernel-migration-plan branch from 5ef2682 to f36e9e6 Compare August 20, 2026 08:55

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at f36e9e62. I isolated your own work from the merged main by comparing every file in the PR blob-by-blob against 5ef26822, then transpiled the kernel, loader, entry store and lock helper at this head and ran them: your 31 tests pass, and I added nine adversarial probes on top.

Disposition of my sixteen standing findings: eleven fixed correctly, four partially fixed, one fixed in a way that moved the problem. The eleven are genuinely closed at the mechanism, not just at my repro:

  • The dispose/install race is serialized through one lifecycle queue, and the test holds a disposer open while racing an install() — that is the right shape.
  • Diagnostics are bounded at 4 KiB with a non-fatal UTF-8 truncation, and recover() degrades to dropping diagnostics rather than aborting when the store hits capacity. I ran 400 entries × 8 KiB of error text through it and recovery came back clean.
  • The bare await now goes through #disposeContextWithDiagnostic, so the activation error is no longer displaced by a cleanup error.
  • __proto__/constructor/prototype are rejected by an explicit key check reached from both assertJsonValue and the inject/intercept clone. Rejecting rather than dropping is the right call.
  • published = true moved after publish() resolves, with abort called exactly once and the generation directory cleaned.
  • The entry store now uses readBoundedMarkerFile/publishMarkerFile: size checked from fstat before any bytes are read, O_NOFOLLOW, dev/ino identity, directory fsync, and temp cleanup on every failure path. The file went from 152 lines to 93.
  • contentDigest and the unused barrel exports were removed rather than justified — both were the option I would have picked.
  • The LegacyFiber/LegacyLoader shim is gone; every fiber now implements the real prepare/publish/dispose, the authority is injected explicitly, and there is a test proving a stale owner is rejected before any fiber is prepared. That last one turns the authority from a parameter into a contract.

The extracted advisory-file-lock.ts is exactly what I asked for — one helper, artifact-writer-lock.ts down from 132 lines to 53, no third variant. Note that artifact-writer-bootstrap-lock.ts is still a near-copy of the same protocol; it predates this PR, so it is follow-up material rather than something to fix here.

Where things stand on the four partial fixes, in order of how much is left:

The immutable-package guarantee did not survive the change of mechanism. Replacing the generation query with a mkdtemp + cp of the whole package tree does fix the multi-file case, and your own test proves it. But Node's cp defaults to dereference: false, so symlinks are copied as symlinks — and a relative intra-package symlink gets rewritten to an absolute path into the original tree. I reproduced both: an absolute link and a purely internal relative link each yield ['first', 'first'] after reload, and printing the link target inside the generation directory shows it pointing back at the mutable source. node_modules workspace links and pnpm store links both have that shape. Inline.

prepare still receives the authoritative Entry object. cloneEntry now guards the getter and the mutation entry points, and your test covers both — but the object handed to #loader.prepare is the live one from nextMap, and #stage runs before the durable write. A package that mutates entry.configuration inside prepare changes both the kernel's projection and the persisted snapshot. Inline.

Deadline and cancellation. The operationTimeoutMs plus AbortSignal threading is real work and I accept the PR body's explicit carve-out for packages that ignore the signal. Two things are outside that carve-out: dispose() can now succeed after its own deadline fires while leaving every effect alive, and effect disposers are never handed a signal at all, so a cooperative package has nothing to cooperate with. Both inline; the first is the one I would fix before merging.

Run-snapshot immutability (#2973). The PR body now states plainly that there is no per-Run immutable Composition Snapshot, which is better than leaving it in commit history. But #2973 is not linked from the PR, its last comment is still from 2026-08-13, and there is nothing on dev@. #commitStage still rewrites the whole live composition in place. I am not filing this inline again — it is a contract decision, and the ask is unchanged: either pin the composition per Run and gate on drain, or record the deviation where #2973 can see it.

The new findings below were not raised by anyone; I checked all twenty inline comments and three issue comments on this PR first. Three are P1, and two of them are new consequences of the lifecycle work rather than pre-existing.

Reviewed with Claude Opus as an analysis assistant. Findings marked "reproduced by execution" were run against transpiled sources at this head in a throwaway worktree that has been removed; the root checkout was not modified and no repository-wide tests were run. Evidence grade is stated per finding.

);
this.#fibers.clear();
this.#state = 'stopped';
for (const active of fibers) await this.#disposeWithDiagnostic(active, 'retirement', signal);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Do not let dispose() report success after its own deadline has fired. Once #runWithDeadline's timeout trips during this disposer loop, every remaining cooperative fiber.dispose(signal) rejects immediately on the abort, #disposeWithDiagnostic swallows each one indistinguishably from an ordinary disposer failure, and dispose() resolves normally with #state at 'stopped' and no lifecycle-timeout emitted — because #runWithDeadline only fences when the operation throws, and here it does not. Reproduced by execution: three fibers where the first burns a 60 ms budget in a 200 ms disposer and the other two abort cooperatively — dispose() resolved, all three effects were still live, diagnostics showed retirement:a/b/c, and no lifecycle-timeout was emitted. The Host's shutdown path awaits this and takes a clean result as permission to exit while sockets, child processes and global registrations survive; and because the state is 'stopped', a subsequent recover() activates them a second time. Regression test: with operationTimeoutMs below the total cleanup cost, assert dispose() rejects — or at minimum emits lifecycle-timeout and leaves the kernel unrecoverable — and that the un-disposed entry ids are enumerable by the caller.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1c6fdc3. Disposal now exhausts cleanup, rejects after its deadline, retains unresolved live Entry ids, emits the lifecycle-timeout fence, and refuses a second in-process cleanup attempt when the outcome is uncertain. Added a deadline regression.

} catch (error) {
await this.#disposeContextWithDiagnostic(context, entry.entryId);
if (signal.aborted) throw signal.reason;
if (!tolerateActivationFailure) throw new ExtensionActivationError(entry.entryId, error);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Let a user disable or remove an Entry that cannot activate. Recovery quarantines a failing Entry and records a diagnostic, but every later mutation re-prepares every enabled Entry that has no fiber — the reuse test at :429 keys on current?.fingerprint, and a quarantined Entry has no current — so the first failure throws here and rolls the whole mutation back. With two or more broken Entries, nothing can be committed at all, including the mutation that would disable one of them. Reproduced by execution: with bad1, bad2 and good, recovery leaves active = ['good'] with both diagnostics recorded, and then setEnabled('bad1', false) is rejected by bad2, remove('bad1') is rejected by bad2, and reconfigure('good', …) is rejected by bad1; all three entries stay enabled: true while the revision climbs from 1 to 5, since each failed attempt still burns a durable revision writing diagnostics. The user's only escape is hand-editing the snapshot file — the exact terminal state the bounded-diagnostics fix was meant to remove, reached through a different door. Either carry tolerateActivationFailure into mutations for Entries that are already quarantined, or scope re-preparation to the Entries the mutation actually touches. Regression test: two independently failing Entries plus a healthy one; assert setEnabled(bad1,false) and remove(bad1) succeed, good's fiber identity is unchanged, and a failed stage does not advance the revision.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1c6fdc3. Unchanged Entries with an existing activation diagnostic remain quarantined during ordinary mutations and are retried only when changed or explicitly reloaded/recovered. Added bad1/bad2/good coverage for disable, remove, and healthy reconfigure.

operation: () => Promise<T>,
): Promise<T> {
return withArtifactWriterLockPath(
return withAdvisoryFileLock(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Do not give existing artifact-writer callers a 30-second budget they never asked for. This call passes no options, so withAdvisoryFileLock applies its timeoutMs ?? 30_000 default to both the queue wait and the OS lock wait — where withArtifactWriterLockPath previously awaited previous and waitForLock(fd) unbounded. The extraction is otherwise faithful: the gate map and the dev/ino identity check are preserved verbatim, and the only other difference is error wording that nothing asserts on. But createOperationalStateBackup holds this lock across a SQLite backup plus copyRegularTree over the whole artifact tree, and git-workspace-service.ts holds it across eight clone/worktree operations — all of which can exceed 30 seconds on a large workspace, and all of which would now hard-fail with Timed out queuing … where they used to queue. Mutual exclusion itself is intact: I confirmed by execution that a timed-out waiter deletes its gate entry and later callers are still excluded by the OFD/flock lock, with no overlap. What changed is waiting versus failing, and FIFO fairness. The helper-level behaviour is reproduced by execution; that these callers take the default is confirmed by reading this line. Give AdvisoryFileLockOptions an explicit unbounded mode and use it here — note that passing Infinity will not work, since Node degrades setTimeout(fn, Infinity) to 1 ms. Regression test: in artifact-writer-lock.test.ts, hold the lock past the default budget and assert a queued withArtifactWriterLock still succeeds.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1c6fdc3. The shared lock now supports an explicit null unbounded mode, and Artifact writer calls it explicitly; the Entry Store retains its bounded timeout.

let generationOwned = true;
try {
signal.throwIfAborted();
await cp(packageRoot, generationPackageRoot, { recursive: true, force: false });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Dereference symlinks when materializing the generation copy, or reject links that escape it. Copying the whole package tree fixes the multi-file reload case your test covers, but cp defaults to dereference: false and verbatimSymlinks: false, so a symlink is copied as a symlink — and a relative one is rewritten to an absolute path pointing back into the original tree. The generation directory is then not a snapshot, and import reads live bytes from a mutable source, contradicting this file's own note that Node cannot mix old and new ESM modules. Reproduced by execution twice: with pkg/dep.mjs as an absolute symlink, and with a purely intra-package relative link pkg/src/dep.mjs -> ../impl/dep.mjs, editing the target and calling reload both returned ['first','first'] — and printing the copied link's target showed it resolving outside the generation root. node_modules workspace links and pnpm store links both have exactly this shape. Pass { dereference: true }, or after copying reject any entry whose realpath leaves generationRoot. Separately worth a note: this now copies the entire tree on every prepare, so a package carrying node_modules pays a full tree copy per reconfigure. Regression test: a package whose dependency is reached through a symlink; assert reload observes the new bytes and that no path inside the generation root resolves outside it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1c6fdc3. Generation copies now dereference links so imported module bytes are materialized inside the ephemeral generation. Added an intra-package relative-symlink reload regression.

throw new Error(`Missing Extension dependencies: ${missing.join(', ')}`);
}
signal.throwIfAborted();
const fiber = await this.#loader.prepare(entry, context, signal);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Hand prepare a clone, not the authoritative Entry. cloneEntry now protects the entries getter and the mutation entry points — your test covers mutating both the caller's input and the projection — but the object passed here is the live one from nextMap, and #stage runs before the durable write. A package that assigns to entry.configuration inside prepare therefore rewrites the kernel's own state and the persisted snapshot. Reproduced by execution: a package setting entry.configuration.mode = 'package-mutated' during prepare left both kernel.entries[0].configuration and the durable snapshot carrying that value. This is the last remaining outlet for the finding I raised earlier, and it is the one that reaches persistence. Clone here as well. Regression test: a loader that mutates its entry argument; assert neither the projection nor the durable snapshot changes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1c6fdc3. The loader receives cloneEntry(entry), and a regression verifies package mutation cannot change either the Kernel projection or durable snapshot.

);
} catch (error) {
await this.#abortStage(staged, signal);
this.#state = 'fenced';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Do not fence the kernel on a deterministic capacity failure, and do not describe it as an authority conflict. FileExtensionEntryStore.write throws ExtensionEntryStoreCapacityError from a Buffer.byteLength check before it takes the lock, so neither durable nor in-memory state has moved — but this catch does not distinguish error kinds (unlike the diagnostic path a few lines above, which already special-cases capacity), so the kernel goes to 'fenced' and every later setEnabled/remove returns Extension kernel is fenced after a durable authority conflict; restart required. There is no authority conflict, and that message sends the next reader after the wrong thing. Configuration comes from the caller with no size limit at any entry point, so a single reconfigure(id, { blob: <2 MB> }) reaches it. Reproduced by execution: after that call the kernel refuses all subsequent mutations, while activeEntryIds correctly still shows the old fiber and the candidate was aborted. Treat capacity like the diagnostic path does — reject the mutation and stay running. Regression test: an oversized reconfigure rejects, the kernel remains running, the fiber identity is unchanged, and a following setEnabled(id, false) succeeds.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1c6fdc3. ExtensionEntryStoreCapacityError now aborts the candidate and rejects without fencing; the old Fiber and running Kernel remain usable. Added an oversized reconfigure regression followed by a successful disable.

readonly parent?: ExtensionContext;
provide<T>(key: string, value: T): void;
inject<T>(key: string): T | undefined;
registerEffect(dispose: () => void | Promise<void>): void;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Give effect disposers the AbortSignal that fiber.dispose already receives. disposeActiveFiber calls active.context.dispose() with no argument, and that awaits each registered effect disposer unbounded, so a single effect whose cleanup never settles makes dispose() hang forever and operationTimeoutMs has no effect. This is distinct from a package ignoring its signal, which the PR body explicitly declines to solve: here there is no signal for a well-behaved package to honour, so the carve-out does not cover it. Reproduced by execution: registerEffect(() => new Promise(() => {})) with operationTimeoutMs: 50 left dispose() still pending after 400 ms. Extend the signature to accept an AbortSignal, or bound the context-cleanup loop in the kernel. Regression test: a non-settling effect disposer; assert dispose() rejects within the deadline and the offending entry id is enumerable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1c6fdc3. Context effect disposers now receive the lifecycle AbortSignal through registerEffect and context disposal. Added a non-settling cooperative effect regression.

@@ -0,0 +1,115 @@
/// <reference path="./fs-native-extensions.d.ts" />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Test the shared primitive directly. This file is now the single lock authority for two unrelated subsystems — the artifact writer and the Extension Entry Tree — and there is no advisory-file-lock test anywhere under packages/storage/src/__tests__/; grepping the repository for Timed out locking and Timed out queuing returns nothing. Coverage is entirely indirect through artifact-writer-lock.test.ts, whose hold times are far below the 30-second default and therefore never reach the timeout branch that the previous finding is about. Three behaviours introduced here are unverified: the timeoutMs semantics, deleting the gate entry on timeout, and closing without unlocking when acquired === false. Confirmed by reading the file list and by grep at this head. The whole point of consolidating the two forks was to stop them drifting; an untested single authority reintroduces that risk in a different form. Add packages/storage/src/__tests__/advisory-file-lock.test.ts covering mutual exclusion, both timeout messages, gate state after a timeout, and assertStableRegularFile rejecting a non-regular file and a symlink.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1c6fdc3. Added direct advisory-file-lock tests for gate/OS timeout, post-timeout reuse, explicit unbounded waiting, mutual exclusion, symlink rejection, and non-regular paths. Bounded OS waits now use cancellable tryLock polling rather than leaving a native waiter behind after Promise.race.

await context.dispose();
} catch (error) {
try {
this.#options.onDiagnostic?.({ phase: 'recovery-cleanup', entryId, error });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Report the phase this cleanup actually ran in. phase: 'recovery-cleanup' is hardcoded, but the sole call site at :450 serves both recovery (tolerateActivationFailure = true) and mutation (false), so a consumer cannot tell "a broken entry was quarantined during recovery" from "a user's mutation failed and its candidate could not be cleaned up" — operationally very different events. Confirmed by reading code at this head. Pass the phase in from the call site. Regression test: a mutation whose prepare fails and whose effect disposer also throws; assert the diagnostic phase is candidate-cleanup rather than recovery-cleanup.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1c6fdc3. Context cleanup receives the phase from its caller, so mutation failures report candidate-cleanup and recovery isolation reports recovery-cleanup. Added the mutation regression.

: { configuration: cloneJsonRecord(entry.configuration) }),
...(entry.dependencies === undefined
? {}
: { dependencies: entry.dependencies.map((value) => requireString(value, 'dependency')) }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Validate dependencies and isolate like the neighbouring fields. cloneEntry gives entryId, scope, enabled, configuration and lastDiagnostic.phase explicit checks with readable errors, but these two are only tested for undefined before .map — and isSnapshot in the entry store checks three fields, so a malformed or cross-version snapshot reaches here. Reproduced by execution: persisting dependencies: { x: 1 } makes recover() fail with TypeError: entry.dependencies.map is not a function, which is both less diagnosable and stylistically inconsistent with every other field. Assert they are arrays. Regression test: extend recovery validates persisted diagnostic phases with a non-array dependencies and isolate, asserting an error matching /must be an array/.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1c6fdc3. dependencies/isolate now require arrays; the same boundary audit also requires inject/intercept and lastDiagnostic to be plain records. Added malformed persisted-shape regressions.

@likun666661

Copy link
Copy Markdown
Member

I think K0 is exactly the right layer to pin down two architectural contracts that the implementation currently answers mostly by mechanism rather than by an explicit statement.

1. What does a committed Entry mean?

The current ordering is prepare → durable CAS → publish, the Entry Tree is the only persisted authority, and there is intentionally no durable Candidate/Last-good model. That appears to make the Entry Tree desired composition, not last-known-good activated composition.

Is the intended contract therefore:

A successful Entry CAS means that the desired composition has been accepted; it does not mean that the runtime projection has published successfully. If publication fails after CAS, the new Entry remains authoritative, the previous Fiber projection may remain temporarily live, and the Kernel is fenced until recovery/reconciliation.

If yes, I think this should be stated as a K0 invariant. It explains the ordering and the absence of Last-good cleanly, but it also means future callers need an explicit way to observe desired-vs-actual divergence. If that is not the contract, where does the authoritative last successfully activated state live after a post-CAS publish failure?

2. What semantic namespace does the Context algebra expose?

provide/inject/isolate/intercept is a powerful string-keyed algebra, but what is the long-term identity and ownership model for those keys?

Concretely:

  • Are keys globally namespaced, scoped by contribution type, or owned by a particular provider/package?
  • Who defines the canonical meaning, value schema, compatibility and versioning of a key?
  • Does dependencies: ['x'] depend on a specific provider/revision, or merely on any ancestor that happens to provide the string x?
  • How are accidental key collisions distinguished from intentional overrides?
  • Can chained inject/intercept renames across ancestors create non-local aliasing, and if so how is that graph inspected and diagnosed?
  • Is it intentional that changing a distant structural parent's isolate or intercept declaration can change the meaning seen by every descendant?
  • May Tool, UI, Hook and Service contributions use the same key with different meanings, or will these eventually become typed/namespaced capability tokens?
  • If typed tokens are expected later, can that migration happen without changing the persisted Entry contract introduced here?

The Occam question is not whether K0 should avoid defining a framework—it should define one. It is whether these four string operations are the minimal stable composition algebra, or whether they prematurely encode a general service locator before the capability identity contract is known. Would K0 be safer with opaque, namespaced capability identities first, adding alias/intercept semantics only when a concrete composition case requires them?

AI-assisted review disclosure: this architectural question was drafted with OpenAI Codex at the reviewer's request.

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The engineering here is careful and the tests are real — 44 cases driving actual module bytes and actual files, not fakes. The Fiber/Context projection, the child-first exhaustive cleanup, and the generation directory in the loader have no precedent in this tree and I found nothing to attack in their shape. What follows is not a quality objection.

The architectural question has to be settled first, and it is not a code-review question. At this head none of the 1238 production lines is reachable from a running Host: packages/runtime-host/src/server/index.ts is unmodified and contains no reference to the kernel, and the only importers of extension-kernel.js are the two other new files and the test. ExtensionKernel also carries its own lifecycle (recover/restart/dispose) and its own 'stopped' | 'running' | 'fenced' state machine next to RuntimeHostKernel, without implementing RuntimeHostDomainModule — so Host shutdown will not dispose Extension fibers and Host recovery will not recover the Entry Tree. AGENTS.md puts lifecycle under Runtime Host's sole authority and asks contributors to extend the closest existing seam; the closest seam here is createRuntimeHostDomainModule in host-composition.ts, registered in execution-composition.ts. Either register it in this PR, or land the kernel with its first consumer. A kernel nothing calls cannot be regression-tested for the property that matters most.

Related and worth clearing up rather than escalating: you also have #3003 open as a draft (last updated 17 Aug) adding extension-controller.ts / extension-loader.ts / extension-state-store.ts to the same directory with a Revision/Binding/Candidate model that this PR's description explicitly repudiates, and #2979 was closed "in favor of the newer extension composition/kernel PRs" without naming a successor. Since all three are yours this is bookkeeping, not a governance dispute — but a reviewer landing on this directory cannot currently tell which model is live. Closing or rescoping #3003, and recording the object-model decision on #2973, would fix that.

One intent, or two? The only reachable behaviour change in this PR is the advisory-lock extraction: artifact-writer-lock.ts now delegates to the new advisory-file-lock.ts. That is independently buildable, testable and valuable, and I found no regression in it — timeoutMs: null preserves the unbounded waitForLock contract, the in-process gate and the double stable-regular-file check carry over intact, and the O_NOFOLLOW plus explicit chmod(0o600) hardening is a genuine improvement. It is also a different revert from the kernel. Splitting it out would land ~290 reviewable lines today and leave what remains honestly described as one dead-code intent.

The findings below are the ones I verified myself against the code at this head. Four are blocking as filed; the deadline and onChanged defects are the ones I would fix regardless of how the wiring question is answered, because they are in the kernel's core control flow rather than in the parts K5+ will rewrite.

AI disclosure: this review was produced with Claude Code (Opus 5), with four subagents covering security, correctness/resource bounds, integration and simplification in parallel. Every finding published here I re-derived myself by reading extension-kernel.ts, extension-entry-file-store.ts, server/index.ts, the storage export map and the PR's file list at e581f0b; I dropped several subagent findings that I could not confirm independently, and corrected their framing of the #3003 relationship. The subagents additionally report reproducing several of these by compiling and driving the kernel standalone; I did not re-run those, so treat the specific transcripts as their evidence rather than mine. Per AGENTS.md this is not independent human review.

}
} catch (error) {
const currentEntries = new Set(this.#liveEntries.values());
const candidates = [...staged.values()].filter((candidate) => !currentEntries.has(candidate));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Give cleanup its own deadline instead of the signal that just expired. #runWithDeadline creates one AbortController and threads its signal everywhere, so when the deadline fires, every cleanup path receives an already-aborted signal — this line, #disposeContextWithDiagnostic at 513, the retirement loop at 575, and #dispose at 390. Any disposer that honours cancellation therefore rejects by construction, and honouring it is exactly what this kernel's own API teaches: registerEffect(dispose: (signal: AbortSignal) => …) hands the disposer a signal, and the test at extension-kernel.test.ts:297 asserts disposers receive it. #disposeWithDiagnostic maps that rejection to #cleanupUncertain = true, and grepping every reference to that flag shows nothing ever clears it: recover throws at 163, mutate at 279, dispose and restart at 379. So one slow extension plus one well-behaved disposer escalates to "the host process must be restarted", with the live fibers left non-terminal and no handle to reach them. The perverse corollary is that a disposer which ignores the signal recovers cleanly — the kernel punishes the cooperative implementation. Confirmed by reading the control flow at this head; a subagent reports reproducing the three-way install/dispose/restart rejection with operationTimeoutMs: 20. Fix: a fresh controller (or AbortSignal.timeout) for the teardown phase. Regression test: a fiber whose effect disposer calls signal.throwIfAborted(), force a mutation timeout, then assert kernel.dispose() resolves and activeEntryIds is empty.

controller.abort(new Error(`Extension ${label} exceeded ${timeoutMs} ms`));
}, timeoutMs);
try {
return await operation(controller.signal);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Race the operation against the deadline — as written the timeout cannot fire on the case it exists for. setTimeout calls controller.abort(...), and then this line plainly awaits the operation. The lifecycle-timeout diagnostic and the #state = 'fenced' transition live only in the catch below, so both require the operation to actually reject. An operation that never settles — a forgotten await, a fetch with no abort wiring, a disposer returning a promise nobody resolves — produces no rejection, so the deadline is a no-op: no diagnostic, no fence, #state stays 'running', and #enqueue above chains #operationTail onto that same never-settling promise, so every later mutate, remove, restart and dispose queues behind it forever. Host shutdown hangs too. The kernel's most carefully built safety property — bounded lifecycle operations with a visible fence — is unreachable precisely when an extension misbehaves in the most ordinary way. Confirmed by reading this function at this head; a subagent reports reproducing the permanent wedge with a fiber whose dispose() returns new Promise(() => {}). Regression test: that fiber with operationTimeoutMs: 10, assert remove() rejects with the timeout, assert a lifecycle-timeout diagnostic was emitted, and assert a subsequently queued setEnabled also rejects rather than hanging.

this.#entries = nextMap;
this.#revision = nextRevision;
try {
await this.#options.onChanged?.({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Dispatch onChanged after the operation leaves the queue, and give it the lifecycle signal. This await runs inside #mutate, which runs inside the single #operationTail slot, and onChanged's signature is (snapshot) => void | Promise<void> — no signal. Two consequences follow directly. First, reentrancy deadlocks: a handler that calls any public kernel method enqueues behind the operation currently awaiting it, and hangs permanently with no diagnostic and no timeout rescue. That is the natural thing for a host to write — a UI mirror reacting to a tree change — and nothing in the type or the doc comment warns against it. Second, because the handler is never given the signal, it cannot cooperate with the deadline even in principle; a handler that never settles wedges the tail forever, which is the same failure as the finding at line 427 but reachable from embedder code rather than extension code. Confirmed by reading #mutate and #enqueue at this head; a subagent reports reproducing both the reentrancy hang and the past-deadline wedge. Regression test: an onChanged that calls kernel.setEnabled — assert the outer install() settles; and one returning a never-settling promise — assert a subsequent dispose() rejects within the deadline.

});

test('advisory lock rejects a symlink lock path', {
skip: process.platform === 'win32',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Regenerate the Windows skip inventory — CI fails on this as filed. .github/workflows/ci.yml runs npm run windows:inventory, whose --check half re-derives docs/windows-test-inventory.md from every skip: expression that excludes Windows and compares it to the committed file. This PR adds two such declarations — this line and packages/runtime-host/src/__tests__/extension-kernel.test.ts:820 — and does not touch the doc, which is not in the PR's file list at all. npm run windows:inventory:write and commit the result. Confirmed from the PR's file list and the two added skip: lines at this head; a subagent reports running the checker against a throwaway worktree and getting Windows test skip inventory is stale; run npm run windows:inventory:write with exit 1, and a rendered delta of 62 to 64 total rows. Purely mechanical, but it is a required check, so it blocks. The regression test is the check itself.

export interface ExtensionContext {
readonly entryId: string;
readonly scope: ExtensionRootScope;
readonly parent?: ExtensionContext;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Do not expose parent on the public ExtensionContext. Isolation is implemented entirely in inject and resolveForChildisolate cuts the upward chain and intercept remaps keys as a lookup descends — but all of it is enforced only along the path that starts at this. Because parent is public and provide/inject/registerEffect are public on it, a fiber reaches its ancestors directly and every declared boundary is skipped: context.parent.parent.inject(k) returns what context.inject(k) was declared to hide. resolveForChild being private does not help; that is a TypeScript annotation, and parent.inject() is the public door. Two further consequences follow from the same field: #validate permits one profile parent to own children in different sessions, so parent.provide(k, v) from one session is resolvable by another through the sanctioned path; and parent.registerEffect(fn) survives remove() of the registering entry, because removal disposes only that entry's own context — so a removed extension still runs teardown at its ancestor's, and since KernelExtensionContext.dispose aggregates effect errors, it can fence the kernel after it is durably gone. Confirmed structurally by reading the interface and the resolution methods at this head; a subagent reports reproducing all three against the PR's own isolate test fixture. The kernel already passes parent contexts by constructor and does not need the public field; alternatively hand fibers a per-entry facade exposing only provide/inject/registerEffect. Regression test: extend the existing isolation test to assert context.parent?.parent?.inject('visible') is also undefined.

}
}

function isSnapshot(value: unknown): value is ExtensionEntrySnapshot {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Version this format and decide its unknown-key policy before anything writes it. isSnapshot checks only that revision is a safe non-negative integer and that each entry has a string entryId, a string scope and a boolean enabled; revision is a CAS counter, not a format discriminator, and there is no schemaVersion. Two gaps follow. Unknown keys are dropped rather than rejected, because cloneEntry builds a whitelist object literal — so an older build round-tripping a snapshot written by a newer one silently discards fields, which is what two Host builds sharing a data root, or a downgrade after an upgrade, will do to each other. And an entry this build cannot interpret fails the whole recovery rather than being quarantined: #recover validates the entire map before any per-entry staging, so one unrecognized scope leaves every healthy sibling inactive and #state at 'stopped', which makes mutate reject — the user cannot even remove the offending entry, and every restart repeats it. That directly contradicts the quarantine property the PR claims, which today covers activation failures only. Worth contrasting with the protocol layer, which gets this right: requireExactRecord rejects unknown keys loudly and RUNTIME_HOST_COMPATIBILITY_EPOCH fences mixed-version peers. This is cheap now, while nothing in production writes the format, and expensive later. Confirmed by reading isSnapshot, cloneEntry and #recover at this head. Regression tests: recover from a snapshot holding one valid entry plus one with an unrecognized scope, and assert the valid one activates and the invalid one can then be removed; and round-trip a snapshot carrying an unknown key, asserting it is preserved or loudly rejected, never silently dropped.

Comment thread packages/storage/package.json Outdated
".": "./dist/index.js",
"./credential-store": "./dist/credential-store.js",
"./file-update-lock": "./dist/file-update-lock.js",
"./advisory-file-lock": "./dist/advisory-file-lock.js",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Coordinate these two export keys with #3301, which rewrites this whole block and contains neither. extension-entry-file-store.ts imports @maka/storage/advisory-file-lock and @maka/storage/marker-file, so both keys are load-bearing for this PR. Open PR #3301 ("refactor(storage): drop the barrel and publish narrow entrypoints") replaces the entire exports map with a regenerated list; grepping its added lines for advisory-file-lock or marker-file returns nothing, which is expected since it was cut before this branch existed. Whichever lands second must re-add both by hand, and a rebase that takes #3301's block wholesale breaks the runtime-host build with ERR_PACKAGE_PATH_NOT_EXPORTED. Worth a note on both PRs so it is caught at rebase time rather than by a red CI run. Confirmed by reading both PRs' diffs at their current heads. The export map is otherwise correct here: marker-file.ts already exists on main with the signatures the call sites use, and fs-native-extensions.d.ts already declares tryLock, so no typings change is owed.

@xxhZs
xxhZs force-pushed the feat/extension-kernel-migration-plan branch from 9e98d92 to 1300bbe Compare August 20, 2026 11:42

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

COMMENT. All four findings from my previous review are still present, unchanged, and the new commit adds roughly 2050 lines that bring three more P1s with them.

Head note. gh pr view reports 1300bbe36, not the 9e98d92f8 I was pointed at. Both have tree 49efaf444 and parent e581f0bc3, so they are the same content and everything below is against the current head.

The four prior findings — all STILL PRESENT. I am not re-posting them; the existing threads at :442, :578 and :377 are still the right place, and none has a "fixed in" reply.

  1. #runWithDeadline:442 is still return await operation(controller.signal); with no Promise.racegrep -c "Promise.race" over the file returns 0. The PR body's defence ("hard termination requires a worker or process boundary and is intentionally not claimed here") does not reach this: the problem is not that the operation keeps running, it is that the returned promise never settles, so the mutation never rejects and #operationTail never drains. That needs no termination primitive to fix.
  2. await this.#options.onChanged?.(...) is still awaited inside the queued mutation at :376-386. The try/catch there isolates a rejecting observer, which was already true at the previous head; a hanging one still stalls every later mutation.
  3. Cleanup still receives the already-aborted signal — :368, :405, :582, :602, :619, :631 all forward the same signal, and disposeLiveEntry:811-820 puts it on packageState.signal, which is what registerEffect's disposer gets. A disposer that honours the signal as the contract asks it to can do no async cleanup at all after a timeout.
  4. #cleanupUncertain is still never cleared. The body now states the fence as intent and I accept that as a contract decision — but the consequence for restart() and dispose() is new and concrete, so I have filed that part inline as a P2.

On the new commit. refactor(runtime): restore full plugin kernel foundation adds a 948-line DI/Fiber framework, a 761-line composition loader, and a 345-line runtime surface, inside a PR titled feat(runtime-host): add Entry/Fiber composition kernel (K0). Two things about that, before the individual findings:

plugin-composition-loader.ts and plugin-runtime.ts are a second composition authority with no production consumer. MakaCompositionLoader re-implements what ExtensionKernel already owns, against the same concepts: the same three root scopes (plugin-runtime.ts:5 vs extension-kernel.ts:8), an entry tree with inject/isolate/intercept, stage-commit-dispose, and an interned isolation-label map (plugin-composition-loader.ts:663-670 vs extension-kernel.ts:667-674, the same routine). AGENTS.md asks for the closest existing seam rather than a parallel path, and Runtime Host is named as the sole authority for lifecycle. It also cuts against the PR's own architecture statement, which says the design has "no per-Run immutable Composition Snapshot" — plugin-runtime.ts:30-38 and :116-121 define exactly that, with a schemaVersion and a sha256: digest.

And on revert granularity: plugin-kernel.ts has a consumer and is arguably in scope; the loader has none. One revert should not have to undo both. I would drop the loader from this PR until the change that wires it, or split it out.

Eight findings inline — three P1 and five P2, all in the new code except the restart/dispose one. I have deliberately not filed several smaller things: void setup; is dead at plugin-kernel.ts:708; FIBER_PENDING/FIBER_FAILED at plugin-composition-loader.ts:30-31 are hand-copied numeric duplicates of FiberState that a reordering would silently break, as is fiberStateName's positional array at plugin-runtime.ts:317-321; isConstructor is /^class\s/ on Function.prototype.toString in both files and is wrong for anything transpiled; awaitSettled():257-265 is an unbounded while (true) with no deadline; and the loader has no AbortSignal anywhere at all, so a hanging apply blocks #mutation permanently with none of the (broken, but present) deadline machinery extension-kernel has.

Security: clean. No import(, eval(, require(, new Function, createRequire, pathToFileURL, readFile or spawn in any of the three new modules. MakaCompositionLoader.install() takes an already-constructed package object — no path, URL, or manifest resolution, and no filesystem access. The only dynamic import() is still extension-package-loader.ts:93, unchanged here and already covered by the open threads at :54 and :84. Nothing puts secrets in logs.

Test coverage. 13 tests and roughly 350 lines against roughly 2050 lines of new code, and the gap is systematic rather than incidental: not one test in either file asserts what happens when a disposer throws, when cleanup fails, or when re-activation fails during move or replaceSubtree — which is where three of the findings below live. MakaCompositionLoader.move, uninstall, replaceSubtree, awaitSettled, enable/disable, Context.mixin/accessor/once/set, Fiber.restart, the Service base class and MakaPluginTransactionBuffer.commit's failure path are all untested. Two assertions are shape-level rather than behavioural: plugin-composition-loader.test.ts:167 asserts generation >= 41, which holds for anything the code could produce, and :186-189 asserts the returned inspection array rather than the resulting composition state.

biome format and biome lint are clean on all five new and changed TS files.

AI disclosure: this review was assisted by Claude (Opus), which performed the initial code search and cross-checking and executed parts of the new kernel in isolation. Every finding published here I re-derived myself by reading the source at 1300bbe36; where I could only confirm the structure rather than witness the failure, the finding says "confirmed by reading" rather than claiming a reproduction.

get<T = unknown>(name: string, strict = true): T | undefined {
const implementation = this.#implementation(name);
if (!implementation) return undefined;
if (strict && implementation.fiber.state !== FiberState.ACTIVE) return undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] A service provided by one plugin never reaches a plugin that injects it — the kernel's central capability does not work. Context#provide runs its registration effect while the providing Fiber is still LOADING. #notifyService (:488-496) calls each dependent's refreshDependencies(), which resolves through Context#get, and this line rejects any implementation whose owner Fiber is not ACTIVE, so the dependent stays PENDING. When the provider later reaches ACTIVE at :782, #setState only emits internal/status — nothing re-runs #notifyService and nothing listens for that transition, so the dependent is never revisited. Provision from the root Context works, because the root Fiber is ACTIVE from construction — and that is the only shape either new test exercises (plugin-kernel.test.ts:26 and plugin-composition-loader.test.ts:53 both provide from the root), which is why the gap is invisible in CI. Confirmed by reading the source at this head. Regression test needed: plugin A provides a service, plugin B injects it, assert B activates and its body runs.

};
fiber = context.plugin(plugin, entry.configuration ?? {});
await fiber.await();
staged.set(entry.entryId, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] An Entry whose Fiber never activated is durably committed, with no error and no diagnostic. #stage stores the staged entry immediately after await fiber.await() without checking fiber.state. A Fiber left PENDING — for instance because it injects a service no one provided, which today includes every plugin-provided service (see my note on plugin-kernel.ts:300) — never ran apply, so packageState.prepared stays undefined, and #commitStage:613 gates the publish on active.packageState.prepared && !active.packageState.published and therefore skips it silently. The mutation resolves, the Entry appears in durable state, the loader's prepare was never called, activeEntryIds stays empty, and no diagnostic is emitted. From the user's side the extension is installed, does nothing, and nothing anywhere says why. This is separately fixable from the service-resolution bug — the kernel already knows the Fiber was not published and simply does not report it. Confirmed by reading the source at this head. Regression test needed: install an Entry whose inject names an unprovided service; assert the mutation rejects or records a diagnostic rather than committing silently.

#enqueue(operation: () => Promise<void>): Promise<void> {
const task = this.#transition.then(operation, operation);
this.#transition = task.catch(() => undefined);
const settled = task.finally(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] #enqueue returns a promise nobody has attached a handler to, so any fire-and-forget Fiber load failure becomes an unhandled rejection. this.#transition = task.catch(() => undefined) attaches a handler to task, but settled = task.finally(...) is a new promise that rejects independently, and storing it in this.inertia does not mark it handled. refreshDependencies discards the return value at :634, :650 and :652. Under Node's default --unhandled-rejections=throw that terminates the process. Two reachable shapes: the ordinary ctx.plugin(x) idiom, where a sub-plugin's apply throws; and the deferred reload path, where a later provide triggers #notifyService -> #enqueue(#load) on a plugin that throws. Note the asymmetry that makes this look like an oversight rather than a design: the unprovide path at :290 wraps fiber.await() in Promise.allSettled, while the provide path at :286 discards the same value. extension-kernel always awaits fiber.await() so it does not reach this today, which is exactly why it will not be caught until the first consumer registers a sub-plugin. Confirmed by reading the source at this head. Regression test needed: a fire-and-forget ctx.plugin whose activation throws, asserting no unhandledRejection fires.

Comment thread packages/runtime/src/plugin-kernel.ts Outdated
if (this.#disposed) return this.inertia;
this.#disposed = true;
await this.#enqueue(async () => {
await this.#unload(FiberState.DISPOSED);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] A failed Fiber.dispose() leaks the Fiber permanently, and every retry then reports success. #unload throws its FiberCleanupError at :812, before #setState(nextState) at :813 — so this await rejects and the three lines of bookkeeping below it never run: the Fiber is never removed from kernel.fibers, runtime.fibers, or its parent's #children. It stays registered with state = UNLOADING and #disposed = true. The retry is worse than the leak: dispose():744 short-circuits on #disposed and returns this.inertia, which #enqueue's finally has already reset to undefined, so the second call resolves immediately with no error while the effect is still live. And because the Fiber was never removed from #children, any ancestor's later #unload maps over it, gets that immediately-resolved promise, and concludes the whole subtree disposed cleanly. ExtensionKernel happens not to mis-report because #disposeWithDiagnostic:644-655 catches the first failure — but the leak is unbounded and the false success is a trap for any other consumer of this kernel. Confirmed by reading the source at this head. Regression test needed: a failing disposer; assert dispose() still rejects on retry and that the Fiber has left kernelFibers().


async #dispose(signal: AbortSignal): Promise<void> {
if (this.#cleanupUncertain) {
throw new Error('Extension cleanup outcome is uncertain; process restart required');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Once cleanup is uncertain the kernel tells the caller to restart, and then refuses to let them. #cleanupUncertain is initialised false at :137, set at :410, :554, :636, :651 and :665, and never cleared anywhere. The PR body now presents the fence as intent, and I accept that as a contract decision — but the public surface contradicts it. restart() (:211-217) routes through #dispose, which throws right here, so restart() can never run; dispose() (:390) throws here too, so the host cannot even release the instance in order to construct a replacement. A single transient disposer failure — one that would succeed on the next attempt — therefore permanently bricks the kernel object, and the error message names a recovery the class cannot perform. Either make restart() bypass the fence (it is the one operation whose whole purpose is to leave the uncertain state), or remove restart() and dispose() from the surface and document that the host must drop the reference. Confirmed by reading the source at this head. Regression test needed: one failing disposal, then assert restart() recovers, or that neither restart() nor dispose() is exposed.

}

async #dispose(entry: LiveEntry): Promise<void> {
await Promise.allSettled([...entry.children].reverse().map((child) => this.#dispose(child)));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Every cleanup failure in this loader is discarded, so remove() and close() report success while effects stay live. This is one of six Promise.allSettled calls in the file — :263, :324, :339, :349, :399, :409 and here — none of which inspects its results, aggregates the rejections, or has any diagnostic channel to report them on. A child disposer that throws is swallowed and the enclosing remove() resolves. That is the exact opposite of the invariant the PR body claims for this design and that extension-kernel actually upholds: "timed-out or otherwise failed cleanup cannot report success" and "child-first exhaustive cleanup and reports aggregated disposer failures". Whatever the two kernels' relationship ends up being, they should not disagree about whether a failed cleanup is allowed to look like a successful one. Confirmed by reading the source at this head. Regression test needed: a failing child disposer; assert remove() and close() reject or surface an aggregated diagnostic.

entry.parent = parent;
const target = parent?.children ?? this.#root(entry.rootId).entries;
target.splice(Math.min(position, target.length), 0, entry);
await this.#rebind(entry);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] A failed move leaves the tree mutated with no rollback, and reports the entry as active in its new position. The four lines above this one splice the entry out of source, reassign entry.parent, and splice it into target — all before this await. If #rebind fails, nothing undoes any of it, and apply's rollback at :188 cannot help: it is gated on appliedOperations > 0, which is incremented only after the awaited call returns, so for a single-op move the counter is still 0 and #replaceSnapshot(before) never runs. The result is that the tree and the snapshot place the entry under the new parent while the live Fiber's Context is still parented under the old one — so its isolate and intercept resolution is the old parent's — and inspect() reports status: 'active'. The caller sees a thrown error and a tree that claims the operation succeeded. Do the splices after #rebind resolves, or capture and restore on failure. Confirmed by reading the source at this head. Regression test needed: a failing re-activation on move; assert the tree, the snapshot and the Context parentage are all unchanged.

let label = this.#kernel.serviceLabels.get(name);
if (!label) {
label = Symbol(name);
this.#kernel.serviceLabels.set(name, label);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Any string property read on a Context permanently mints a Symbol here, so serviceLabels grows without bound. The Context proxy's get trap routes any unknown string property to target.get(property) (:531-533), which reaches #implementation -> #label, and #label caches a Symbol(name) for every name it is ever asked about. validateServiceName guards provide, isolate and intercept, but not this path, so the key space is whatever any plugin happens to read — including typos and ordinary property probes. Nothing prunes the map. Have #label take a create-or-not flag and let the read path use a non-minting lookup. Confirmed by reading the source at this head. Regression test needed: assert serviceLabels does not grow for property reads that resolve to no service.

"./path-containment": "./dist/path-containment.js",
"./plan-mode": "./dist/plan-mode.js",
"./plan-tools": "./dist/plan-tools.js",
"./plugin-composition-loader": "./dist/plugin-composition-loader.js",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scope objection rather than a defect, so no P level. These three subpath exports publish MakaCompositionLoader and the whole plugin-runtime surface as public contracts of @maka/runtime, and at this head nothing in the repository imports either module except the loader's own test file — only plugin-kernel.ts has a real consumer (extension-kernel.ts:1058-1064). This is the same objection as the open thread on packages/runtime-host/src/server/index.ts:12 ("Add the barrel exports in the PR that wires a consumer"), reappearing in a new package. Publishing an export is the hardest thing here to walk back later. Confirmed by reading the diff and the source at this head.

@xxhZs
xxhZs force-pushed the feat/extension-kernel-migration-plan branch from 2f49db1 to c3d06d9 Compare August 20, 2026 14:44
@xxhZs xxhZs changed the title feat(runtime-host): add Entry/Fiber composition kernel (K0–K4) feat(runtime): add plugin composition foundation Aug 20, 2026
@xxhZs
xxhZs force-pushed the feat/extension-kernel-migration-plan branch 8 times, most recently from a640896 to 9050cb3 Compare August 20, 2026 17:19
Migrate the feat-defined Context/Fiber kernel, plugin runtime contracts, composition loader, and public Runtime entrypoints. Keep Host control, persistence, package loading, and product wiring out of scope.

Retain regression-tested foundation fixes for Service activation, Fiber transition and config semantics, Effect ownership and disposal, weak Plugin runtime caching, composition Tree failure invariants and inspection, safe Service record names, and contribution registration.

Defer atomic Service-provider replacement and rollback, candidate Context Effect publication, and full asynchronous Effect failure policy to follow-up work that can define the required lifecycle contracts.

Generated-by: Codex
@xxhZs
xxhZs force-pushed the feat/extension-kernel-migration-plan branch from 9050cb3 to 25492b3 Compare August 20, 2026 18:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants