← Vissza a bloghoz
ágensállapotmentestartóseszközjóváhagyás

Túlélni a kilakoltatást: tartós eszköz-jóváhagyások az ágensek állapotmentes világában

·ARDINSYS Codumentor

Amikor egy AI-ágens egy veszélyes eszközhöz nyúl — rm -rf, egy adatbázis DROP, egy éles telepítés —, előbb meg kell kérdeznie a felhasználót. Ez a könnyű rész. A nehéz az, ami ezután történik.

A legtöbb rendszer úgy oldja meg ezt, hogy leparkolja az ágenst a memóriában: egy asyncio.Event várja a felhasználó válaszát, és az ágens teljes állapotát a RAM-ban tartja, amíg valaki az „Engedélyezés” vagy az „Elutasítás” gombra nem kattint. Az ágens nem üríthető ki. A kapcsolatnak élnie kell. Ha a folyamat újraindul, a jóváhagyás elvész.

A Codumentor más utat választ: az ágens elmehet. A feladata visszatér, az ágens LRU-kiürítésre jogosulttá válik, a folyamat akár újra is indulhat — és amikor a felhasználó órákkal később válaszol, a végrehajtás pontosan attól az eszközhívástól folytatódik, anélkül hogy az LLM-et újra meg kellene kérdezni.

Ez a cikk bemutatja a rendszert, amelyet ennek érdekében építettünk, beleértve azt a három alattomos hibát is, amelyek a végső tervet formálták.

A probléma: rögzítés kontra kiürítés

Vegyük egy engedélykérés életciklusát egy tipikus ágensrendszerben:

  1. Az ágens meghív egy veszélyes eszközt
  2. A rendszer „engedély szükséges” jelzést ad
  3. Az ágens a memóriában várakozik a felhasználó válaszára
  4. A felhasználó válaszol; az ágens folytatja

A 3. lépés a probléma. Az ágens rögzítve van — memóriát foglal, kapcsolatokat tart nyitva, és nem szabadítható fel. Egy több száz párhuzamos beszélgetést kiszolgáló rendszerben ez azt jelenti, hogy minden függőben lévő jóváhagyás korlátlan ideig fogyasztja az erőforrásokat. Ami még rosszabb: ha a folyamat újraindul (telepítés, összeomlás, OOM), minden függőben lévő jóváhagyás elvész, és az ágensnek elölről kell kezdenie.

Az alternatíva — visszatérni az ágens feladatából, és később folytatni — egyszerűnek hangzik, de komoly helyességi kihívásokat vet fel:

  • Az ágens memóriabeli állapota (átirat, jegyzettömb, kontextusváltozók) elvész
  • Az eszközhívásokat tartalmazó asszisztensüzenetnek már perzisztálva kell lennie — különben nincs honnan folytatni
  • Az ugyanabban a körben korábban befejezett eszközeredményeknek meg kell maradniuk
  • A folytatási útvonalnak ki kell hagynia az LLM-hívást, és egyenesen a várakozó eszköz végrehajtására kell ugrania

Ezek nem apró aggályok. Mindegyik egy-egy helyességi invariáns, amelynek megsértése csendes adatsérülést vagy végtelen ciklust okoz.

Négy tervezési cél

A tervezési dokumentumunk négy megkerülhetetlen követelményt rögzít:

  1. A jóváhagyások túlélik a kiürítést és az újraindítást — a függőben lévő kérés SQLite-ban van, nem a memóriában
  2. Az ágens futása visszatér — nincs parkolás, nincs asyncio.Event; a feladat tisztán kilép, így az ágens LRU-alapon kiüríthetővé válik
  3. A folytatás tisztán lép vissza — egy friss ágenspéldány ott folytatja, ahol a régi abbahagyta, a rögzített döntés alapján
  4. A felhasználói felület jelzi az állapotot — egy narancssárga pötty mutatja a turn_status === "awaiting_approval" állapotot, így a felhasználók tudják, hogy az ágens várakozik

Három teherhordó invariáns

Az egész rendszer három ellenőrzött invariánson nyugszik. Ha bármelyik sérül, a tartós jóváhagyási mechanizmus csendben meghibásodik.

1. invariáns: Az eszközhívásokat tartalmazó asszisztensüzenet lemezre kerül, mielőtt bármely eszköz lefutna.

A streaming.py-ban a sorrend szigorú:

# streaming.py:245 — persist FIRST
await self.storage.store_assistant_message_with_tool_calls(
    message=assistant_msg, tool_calls=tool_calls,
    session_id=session_id, user_id=user_id,
)

# streaming.py:267 — execute SECOND
async for output in self.tool_runtime.execute_streaming(tool_calls, ...):
    yield output

Ha az eszközök a perzisztálás előtt futnának, egy végrehajtás közbeni kiürítés után nem maradna nyoma annak, mely eszközök lettek meghívva — a folytatási útvonal nem tudná, mit kell végrehajtania.

2. invariáns: A request_permission-nek egyetlen hívója van.

Az engedélykezelő request_permission() metódusát kizárólag az agent_runner.py:553 hívja, input_callback-ként injektálva. Így pontosan tudjuk, honnan ered a jóváhagyási jelzés, és át tudjuk látni a hívási vermet.

3. invariáns: Az átirat és a plugin-események túlélik a kiürítést; a memóriabeli állapot nem.

Az üzenetek SQLite-ban vannak, és megmaradnak. A plugin-események SQLite-ban vannak, és megmaradnak. De az ágenspéldány, a jegyzettömbje és a memóriában lévő PendingPermission objektumok — a kiürítés után mind eltűnnek. A tartós tárolónak mindent tartalmaznia kell, ami a folytatáshoz szükséges.

A jóváhagyási folyamat: felfüggesztés

Kövessük végig, mi történik, amikor egy ágens engedélyköteles eszközt hív meg.

1. lépés: Engedélyellenőrzés

A tool_runtime.execute_streaming()-en belül minden eszközhívás engedélyellenőrzésen megy át:

# tool_runtime.py — permission check before execution
if perm_request is not None:
    _perm_cv_token = current_tool_call_id_var.set(tool_call.id)
    try:
        allowed = await self.permission_provider.request(
            tool_name=tool_call.name,
            tool_call_id=tool_call.id,
            request=perm_request,
        )
    finally:
        current_tool_call_id_var.reset(_perm_cv_token)

Figyeljük meg a current_tool_call_id_var-t — egy contextvars.ContextVar, amely az aktív tool_call_id-t aszinkron határokon át is továbbviszi. Ez biztosítja, hogy az engedélyszolgáltató helyesen kulcsolja a tartós sorokat akkor is, ha a hívási lánc több aszinkron await ponton halad át.

2. lépés: Tartós perzisztálás és jelzés

Az engedélyszolgáltató továbbhív a permission_manager.request_permission()-be. A döntési folyamat:

async def request_permission(self, conversation_id, turn_id,
                              tool_call_id, tool_name,
                              permission_type, details,
                              emit_callback) -> PermissionDecision:
    """
    Decision flow:
    1. Auto-approve-all (still honours deny rules).
    2. Per-conversation always-allow (still honours deny rules).
    3. Rule-based deny / allow.
    4. Durable resume: if a row for this tool call already has
       a decision, return it synchronously.
    5. Otherwise persist a new pending row, emit permission.request,
       and raise ApprovalPending so the agent task unwinds.
    """

    # Steps 1-3: policy checks (omitted for brevity)

    # Step 4: check for existing decision (resume path)
    existing = self._approval_store.get_pending_approval_by_tool_call(
        conversation_id, tool_call_id,
    )
    if existing is not None and existing.decision is not None:
        return PermissionDecision(existing.decision)

    # Step 5: first call — persist and raise
    request_id = f"perm_{conversation_id}_{tool_call_id}"
    self._approval_store.create_pending_approval(
        request_id=request_id,
        conversation_id=conversation_id,
        turn_id=turn_id,
        tool_call_id=tool_call_id,
        tool_name=tool_name,
        permission_type=permission_type,
        details=details,
    )
    await emit_callback("permission.request", { ... })
    raise ApprovalPending(
        request_id=request_id,
        conversation_id=conversation_id,
        turn_id=turn_id,
        tool_call_id=tool_call_id,
        tool_name=tool_name,
        permission_type=permission_type,
        details=details,
    )

A perm_{conversation_id}_{tool_call_id} request_id-formátum szándékos — egy korábbi verzió csak perm_{tool_call_id}-t használt, ami beszélgetések közötti ütközéseket okozott, amikor a mock fixture-ök (és időnként valódi LLM-ek) ugyanazt a tool_call_id-t használták több beszélgetésben. Az INSERT OR IGNORE csendben kihagyta a második beszélgetés sorát, a folytatási útvonal pedig az elavult sor már meghozott döntését olvasta ki.

3. lépés: A kivétel visszagörgeti a vermet

Az ApprovalPending kivétel felfelé terjed. Itt válik fontossá az 1. hiba és a 2. hiba:

1. hiba: Az execute_tool minden kivételt elnyel.

Az eszközregiszterben van egy tág kivételkezelő:

# agent/tools.py — the broad catch that almost swallowed ApprovalPending
try:
    result = await tool.execute(args)
except Exception as exc:
    # Converts ALL exceptions to error ToolResults
    return ToolResult(error=str(exc), tool_call_id=tool_call_id)

Ha e tág elkapás előtt nincs egy célzott except ApprovalPending: raise, a jóváhagyási jelzés hibás ToolResult-tá alakul — az ágens sosem függesztődik fel, és a felhasználói felület sosem jelenít meg engedélykérést. A javítás:

try:
    result = await tool.execute(args)
except ApprovalPending:
    raise  # Must come before the broad except!
except Exception as exc:
    return ToolResult(error=str(exc), tool_call_id=tool_call_id)

2. hiba: A reconcile_orphan_tool_results megmérgezi az átiratot.

A streaming.py-ban egy tág except BaseException kezelő takarítja el az árva eszközhívásokat:

# streaming.py — critical exception ordering
try:
    async for output in self.tool_runtime.execute_streaming(tool_calls, ...):
        yield output
except ApprovalPending:
    # MUST come before except BaseException — otherwise
    # reconcile_orphan_tool_results writes a synthetic "canceled"
    # tool message for the awaiting tool_call_id, committing a
    # result before the user decides and poisoning the resume path.
    raise
except BaseException:
    # Handles AbortTurnSignal, CancelledError, etc.
    slp.reconcile_orphan_tool_results(self, tool_calls, session_id, user_id)
    raise

Ha az ApprovalPending-et nem kapjuk el elsőként, a reconcile_orphan_tool_results egy szintetikus „canceled” eszközüzenetet gyárt a várakozó tool_call_id-hoz. Ez egy eredményt rögzít az átiratban, mielőtt a felhasználó döntött volna — amikor a felhasználó később válaszol, a rendszer befejezett eszközhívást lát, és kihagyja a végrehajtást. A jóváhagyás gyakorlatilag elutasításra kerül, anélkül hogy bárki tudna róla.

4. lépés: A testvérhívások tartóssága

3. hiba: Több eszközhívás egy körön belül.

Egy asszisztenskör gyakran több eszközhívást is létrehoz. Ezek sorban futnak le a tool_runtime.py-ban. Ha az 1. eszköz sikeres, a 2. eszköz ApprovalPending-et dob, és az ágenst a folytatás előtt kiürítik — az 1. eszköz eredménye elvész, mert csak egy memóriabeli listában létezett.

A javítás: minden eszközeredményt azonnal perzisztálunk, nem pedig a végén, kötegben:

def _persist_single_result(self, result: ToolResult,
                            session_id: str, user_id: str) -> None:
    """Persist one tool result immediately. Keeps completed siblings
    durable when a later tool in the same batch raises ApprovalPending."""
    if result.tool_call_id in self._persisted_tool_call_ids:
        return  # Idempotent

    message = Message(
        role="tool", content=result.content,
        tool_call_id=result.tool_call_id,
    )
    self.storage.add_message(message, session_id, user_id)
    self._persisted_tool_call_ids.add(result.tool_call_id)

Minden sikeres eszközvégrehajtás után:

tool_results.append(result)
self._persist_single_result(result, session_id, user_id)

Ettől a köteg végi store_results hívás idempotenssé válik — csak azokat az eredményeket írja ki, amelyek egyenként még nem lettek perzisztálva.

5. lépés: Az ágens feladata visszatér

A streaming.py:run_loop legfelső szintjén az ApprovalPending-et még egyszer elkapjuk:

except ApprovalPending:
    # Suspended turn — let the caller translate this into
    # a turn.awaiting_approval SSE event. Do not log as error,
    # do not emit onError, do not yield a user-facing error string.
    raise

Az ágensfuttató elkapja, perzisztálja a plugin KV-pillanatképeit, kibocsát egy turn.awaiting_approval SSE-eseményt, és visszatér az ágens feladatából. Az ágens többé nincs rögzítve — LRU-kiürítésre jogosult. A folyamat újraindulhat. A jóváhagyás az SQLite-ban várakozik.

Az SQLite-tároló

A függőben lévő jóváhagyások egy dedikált táblában élnek:

CREATE TABLE ui_pending_approvals (
    request_id        TEXT PRIMARY KEY,
    conversation_id   TEXT NOT NULL,
    turn_id           TEXT NOT NULL,
    tool_call_id      TEXT NOT NULL,
    tool_name         TEXT NOT NULL,
    permission_type   TEXT NOT NULL,
    details_json      TEXT NOT NULL,
    created_at        TEXT NOT NULL,
    resolved_at       TEXT,
    decision          TEXT,
    kv_snapshot_json  TEXT,
    UNIQUE(conversation_id, tool_call_id)
);

CREATE INDEX idx_pending_approvals_conv_unresolved
    ON ui_pending_approvals(conversation_id) WHERE resolved_at IS NULL;

A lezáratlan jóváhagyásokra épülő részleges index lehetővé teszi egy beszélgetés összes függőben lévő kérésének gyors lekérdezését. A UNIQUE(conversation_id, tool_call_id) megkötés megakadályozza a duplikált sorokat. A kv_snapshot_json oszlop munkamenetenkénti kontextusváltozó-pillanatképeket tárol, így a plugin-állapot túléli a felfüggesztés/folytatás ciklust — ezt a visszafelé kompatibilitás érdekében online migrációval adtuk hozzá.

Minden művelet egy szálzárral védett, és az atomicitás érdekében SQLite-tranzakciókat használ:

def create_pending_approval(self, request_id, conversation_id,
                             turn_id, tool_call_id, tool_name,
                             permission_type, details: dict) -> None:
    with self._lock, self._connect() as conn:
        conn.execute(
            """INSERT OR IGNORE INTO ui_pending_approvals(
                request_id, conversation_id, turn_id, tool_call_id,
                tool_name, permission_type, details_json, created_at
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
            (request_id, conversation_id, turn_id, tool_call_id,
             tool_name, permission_type, json.dumps(details), utc_now_iso()),
        )
        conn.commit()

A felhasználó válaszol

Amikor a felhasználó a felületen az „Engedélyezés” vagy az „Elutasítás” gombra kattint:

async def resolve_permission(self, request_id: str,
                              decision: PermissionDecision,
                              expected_conversation_id: Optional[str] = None
                              ) -> bool:
    # Validate ownership
    if expected_conversation_id:
        # Ensures the decision is applied to the right conversation
        ...

    # Handle ALWAYS_ALLOW — cache for future requests
    if decision == PermissionDecision.ALWAYS_ALLOW:
        self._always_allow[expected_conversation_id].add(tool_name)
        decision = PermissionDecision.ALLOW

    # Stamp the row atomically
    return self._approval_store.resolve_pending_approval(
        request_id, decision.value, expected_conversation_id
    )

A resolve_pending_approval metódus egy atomi UPDATE-et hajt végre, amely beállítja a decision és a resolved_at mezőt. Ezután egy folytatási feladat kerül ütemezésre.

A folytatási útvonal

A folytatásnál mutatja meg igazán a terv, mit tud. Egy friss ágenspéldány (akár egy újraindított folyamatban) pontosan ott folytatja, ahol a régi megállt.

Belépési pont: run_loop_from_pending_tool_calls

Ahelyett, hogy új kört indítanánk (ami meghívná az LLM-et), egy speciális folytatási útvonalon lépünk be:

async def run_loop_from_pending_tool_calls(
    self,
    messages: List[ChatMessage],
    pending_tool_calls: List[ToolCall],
    session_id: str, user_id: str,
    run_id: Optional[str] = None,
    ctx: Optional[Dict[str, Any]] = None,
) -> AsyncGenerator[str, None]:
    """Resume an interrupted turn by executing the pending tool calls
    without re-asking the LLM for the assistant message.

    The assistant message carrying pending_tool_calls is already in storage.
    We execute the awaiting tool — whose permission provider now sees the
    recorded decision and returns synchronously — persist results, then
    hand control to the normal run_loop for follow-up iterations.

    Callers must pass pending_tool_calls filtered to ids that have no
    persisted tool result yet; completed siblings must not be re-run.
    """

Ez a kulcsfontosságú architekturális döntés: kihagyjuk az LLM-et, és egyenesen az eszközvégrehajtásra ugrunk. Az asszisztensüzenet már az átiratban van (1. invariáns). Csak a hátralévő eszközöket kell végrehajtanunk.

A varázslat: a tárolt döntés szinkron módon tér vissza

Amikor a várakozó eszköz engedélyellenőrzése újra lefut, a request_permission a 4. lépésbe fut bele:

existing = self._approval_store.get_pending_approval_by_tool_call(
    conversation_id, tool_call_id,
)
if existing is not None and existing.decision is not None:
    # Resume call — return the recorded decision synchronously
    logger.info(
        f"Permission request resumed for tool_call_id={tool_call_id} "
        f"(decision={existing.decision})"
    )
    return PermissionDecision(existing.decision)

Nincs ApprovalPending dobás. Nincs SSE-esemény. Az eszköz a felhasználó döntésével normálisan lefut. Miután minden függőben lévő eszköz befejeződött, a vezérlés a szokásos run_loop-hoz kerül a következő LLM-iterációhoz:

# After pending tools complete, continue with normal loop
async for chunk in self.run_loop(
    messages, session_id, user_id, run_id=run_id, ctx=ctx,
):
    yield chunk

A KV-pillanatkép visszaállítása

A ctx["kv"]-ben tárolt plugin-állapot elveszne, amikor az ágens újraépül. A KV-pillanatkép mechanizmus megőrzi:

Felfüggesztéskor minden elkapási pont rögzíti a kontextust:

def capture_kv_into_approval(exc: "ApprovalPending", ctx: Any) -> None:
    """Stash ctx['kv'] onto an in-flight ApprovalPending.
    Uses ctx['session_id'] as the key so resumed agents look up their own kv."""
    session_id = ctx["session_id"]
    exc.kv_snapshots[session_id] = serializable_kv_snapshot(ctx["kv"])

A nem szerializálható bejegyzéseket (zárak, fájlkezelők) csendben eldobjuk. A pillanatkép a jóváhagyási sor kv_snapshot_json oszlopába kerül.

Folytatáskor a pillanatkép visszaáll:

# Restore main agent's kv directly
if approval_record.kv_snapshot:
    ctx["kv"] = approval_record.kv_snapshot.get(session_id, {})

Az alágensek KV-pillanatképei a ctx["_resume_kv_snapshots"]-ban utaznak, és a subagent_runner veszi ki őket lefelé haladtában.

A teljes folyamat

sequenceDiagram
    participant UI as User Interface
    participant PM as Permission Manager
    participant TS as SQLite Store
    participant TR as Tool Runtime
    participant SS as Streaming Loop
    participant AR as Agent Runner
    participant LLM as LLM

    Note over AR,TS: Phase 1: Suspend
    AR->>LLM: Generate response with tool calls
    LLM-->>AR: Assistant message + tool calls
    AR->>SS: run_loop(tool_calls)
    SS->>SS: Persist assistant message to storage
    SS->>TR: execute_streaming(tool_calls)

    loop Each tool call
        TR->>PM: request_permission()
        alt Auto-approve / rule match
            PM-->>TR: ALLOW (sync)
            TR->>TR: Execute tool
            TR->>TS: _persist_single_result()
        else No policy match
            PM->>TS: create_pending_approval()
            PM->>UI: Emit permission.request SSE
            PM-->>TR: raise ApprovalPending
            TR->>TR: Persist completed siblings
            TR-->>SS: ApprovalPending propagates
            SS-->>AR: ApprovalPending propagates
            AR->>TS: _persist_kv_snapshot()
            AR->>UI: Emit turn.awaiting_approval SSE
            AR-->>AR: Return (agent evictable)
        end
    end

    Note over UI,TS: Phase 2: User Responds
    UI->>PM: resolve_permission(ALLOW)
    PM->>TS: resolve_pending_approval(decision=ALLOW)
    TS-->>PM: Updated

    Note over AR,TS: Phase 3: Resume
    AR->>SS: run_loop_from_pending_tool_calls()
    SS->>TR: execute_streaming(pending_tool_calls)
    TR->>PM: request_permission()
    PM->>TS: get_pending_approval_by_tool_call()
    TS-->>PM: Row with decision=ALLOW
    PM-->>TR: ALLOW (sync, no raise)
    TR->>TR: Execute tool
    TR->>TS: Persist result
    TR-->>SS: Complete
    SS->>SS: run_loop() for next LLM iteration
    SS->>LLM: Continue conversation

Miért számít ez?

A tartós jóváhagyási rendszer nem csupán az erőforrás-gazdálkodásról szól. Hanem a hibák melletti helyességről:

  • Folyamat-újraindítások: Telepítések, összeomlások, OOM-leállítások — a jóváhagyások túlélik, mert SQLite-ban vannak, nem a memóriában
  • LRU-kiürítés: A rendszer több ezer beszélgetést tud kiszolgálni anélkül, hogy ágenseket rögzítene a memóriában
  • Kíméletes leromlás: Ha az LLM-hívás egy kör közepén meghiúsul, a befejezett eszközeredmények már perzisztálva vannak
  • Nincsenek csendes elutasítások: Az átirat sosem tartalmaz függőben lévő jóváhagyásokhoz gyártott „canceled” eredményeket

A három hiba, amelybe belefutottunk — elnyelt kivételek, megmérgezett átiratok, elveszett testvér-eredmények —, pontosan az a fajta probléma, amely csak éles környezetben, terhelés alatt jön elő. Nem akadémiai aggályok; ezeken múlik, hogy egy rendszer működik-e, vagy csendben rosszul viselkedik.

Kompromisszumok

Egyetlen terv sincs ingyen. A tartós jóváhagyási rendszernek is megvannak a költségei:

  • Komplexitás: A kivételsorrendre vonatkozó követelmények, az iterációnkénti perzisztálás és a KV-pillanatkép mechanizmus jelentős komplexitást ad hozzá egy egyszerű asyncio.Event-hez képest
  • SQLite-versengés: A jóváhagyási tároló szálzárat használ — extrém párhuzamosság mellett ez szűk keresztmetszetté válhat (bár a gyakorlatban az engedélykérések elég ritkák ahhoz, hogy ezt még nem tapasztaltuk)
  • Folytatási késleltetés: Egy friss ágenspéldány felépítése folytatáskor tovább tart, mint egy leparkolt feladat folytatása — bár az alternatíva (az ágensek örökös rögzítése) nagy léptékben rosszabb
  • A KV-pillanatkép korlátai: A nem JSON-szerializálható plugin-állapotot csendben eldobjuk — a pluginoknak szerializálhatóan kell tartaniuk a ctx["kv"] bejegyzéseiket

Ezek a kompromisszumok elfogadhatók az előnyökért cserébe: helyesség, kiüríthetőség és újraindítással szembeni ellenálló képesség.

Tanulságok

A tartós jóváhagyások az ágensek állapotmentes világában megkövetelik, hogy a jóváhagyást elsőrangú, perzisztált entitásként kezeljük — nem memóriabeli jelzésként. A legfontosabb elvek:

  1. Perzisztálás a végrehajtás előtt — az eszközhívásokat tartalmazó asszisztensüzenetnek lemezen kell lennie, mielőtt bármely eszköz lefut
  2. Előbb a specifikus, aztán a tág elkapás — az ApprovalPending-et minden szinten az Exception vagy a BaseException előtt kell elkapni
  3. Inkrementális perzisztálás — minden sikeres eszközeredmény azonnal kiírásra kerül, nem a végén, kötegben
  4. Az LLM kihagyása folytatáskor — az eszközvégrehajtásnál kell visszalépni, nem az ágensciklus elején
  5. Kontextus-pillanatkép — a ctx["kv"]-ben lévő plugin-állapotnak túl kell élnie az ágens újraépítését

A rendszer egyszerűségében elegáns: egy SQLite-sor, egy felfelé terjedő kivétel és egy folytatási útvonal, amely megbízik a tárolt döntésben. Az elegancia azonban azon múlik, hogy a kivételsorrend, a perzisztálás időzítése és a kontextus visszaállítása pontosan jó legyen.

Erre három alattomos hiba tanított meg minket.


Ez a cikk egy folyamatban lévő sorozat része, amely a Codumentor architektúráját mutatja be.