Add a name on ai client and resume from it #6 #7

Open
opened 2026-10-27 11:46:48 +00:00 by SebastienDenooz · 8 comments

Based on the work of issue 1 "Add a name on client and resume from it", integrate this in the developpment process.

When you start a ai client agent like claude code, codex, etc... Attache to it a name isued from the branch name and on each run, resume from it.

Functional analysis

Source of truth. Issue 1 specified this behaviour in docs/specification.md §16.5 "Named sessions and resume", together with the data model (§ llm_sessions: session_name, native_session_id, resumed, resume_fallback), the setting AGENT_SESSION_RESUME (default true, organization and project scope), runtime detection (§16.6) and the test matrix. None of it is implemented yet: those names exist only in the specification. This issue makes the engine apply it.

Interpretation. "Integrate it in the development process" means every local-agent run of an issue mailbox, in every phase (analysis, development, ci_fix, review_fix, conflict_resolution, answer), names its CLI session after the issue branch and resumes the session left by the previous run.

Expected behaviour

  1. When it applies. All of these must hold: AGENT_SESSION_RESUME is on (project, else organization, else default true); the bound adapter declares resumableSessions for the installed CLI version; the run executes in a container runner; the target is an issue mailbox. If any one is false, the request, mounts and session row are exactly what they are today.
  2. Name. The session name is the issue branch name {BRANCH_PREFIX}issue-{iid}-{slug}, frozen in mailboxes.branch_name at the first run. Analysis runs use it too, even though the branch does not exist yet. When the CLI supports a human-readable session label, the adapter sets it to this name.
  3. Native id. The adapter maps the name to the CLI's native session id and reports it in LlmResult::$sessionId. The engine stores it in llm_sessions.native_session_id. On the next run it reads the id back from the database only (latest row of the same mailbox and same adapter_id with a non-null id), never from the session state directory.
  4. Resume or fresh.
    • Prior id for the same adapter: the CLI starts in resume mode.
    • No prior id: a fresh session starts under the name, with resume_fallback = no_prior_session.
    • Prior id only for another adapter: a fresh session starts, with adapter_changed.
    • A change of provider or model with the same adapter still resumes.
  5. Silent fallback. If a resume fails in a way the adapter recognises (state_missing, unsupported_version, refused), the adapter runs the CLI once more in a fresh session under the same name, within the same run and the remaining timeout. The fallback never fails the run, never counts as an LLM retry, and changes no status, counter, label or comment.
  6. Prompt unchanged. The prompt still carries the full phase context.
  7. State storage. Session state lives in {base}/.agent-sessions/{organization}-{project}-issue-{iid}/{adapter_id}/, mounted at the adapter's sessionStatePath. It is removed at mailbox cleanup and left untouched when the setting is turned off.
  8. Visibility. The run screen shows the session name, the native session id and whether the run resumed.

Acceptance criteria

  • AGENT_SESSION_RESUME setting (default true, organization and project override).
  • Adapter capability resumableSessions and sessionStatePath, declared per local adapter (Claude Code: --session-id <uuid> for a fresh session and --resume <id> to resume; Codex, OpenCode, Copilot CLI and Grok Build each according to their CLI). The capability is lowered by runtime detection when the installed version falls outside the verified range.
  • llm_sessions gains session_name, native_session_id, resumed and resume_fallback. They stay null or false whenever the feature does not apply.
  • Two consecutive runs of the same issue mailbox with the same adapter: the second one resumes the native id recorded by the first.
  • First run → fresh session, no_prior_session. Adapter changed between phases → fresh session, adapter_changed. Provider or model change → resumes.
  • A refused or failed resume falls back to one fresh session in the same run, with the reason recorded, and no failure, retry, label or comment.
  • API mode, an adapter without the capability, the host runner, MR mailboxes and the setting off all produce exactly today's request and mounts.
  • The session state directory is mounted per mailbox and adapter and is removed at mailbox cleanup.
  • The run screen shows the session name, the native id and the resumed flag.
  • Unit and feature tests cover the cases above (FakeLlmAdapter gains resumableSessions, reports a native id and can simulate a refused resume). Playwright e2e covers the run screen fields. All of them run on both GitLab CI and Forgejo/GitHub Actions.
Based on the work of issue 1 "Add a name on client and resume from it", integrate this in the developpment process. When you start a ai client agent like claude code, codex, etc... Attache to it a name isued from the branch name and on each run, resume from it. <!-- agent:section:start --> ## Functional analysis **Source of truth.** Issue 1 specified this behaviour in `docs/specification.md` §16.5 "Named sessions and resume", together with the data model (§ llm_sessions: `session_name`, `native_session_id`, `resumed`, `resume_fallback`), the setting `AGENT_SESSION_RESUME` (default `true`, organization and project scope), runtime detection (§16.6) and the test matrix. None of it is implemented yet: those names exist only in the specification. This issue makes the engine apply it. **Interpretation.** "Integrate it in the development process" means every local-agent run of an issue mailbox, in every phase (analysis, development, `ci_fix`, `review_fix`, `conflict_resolution`, `answer`), names its CLI session after the issue branch and resumes the session left by the previous run. ### Expected behaviour 1. **When it applies.** All of these must hold: `AGENT_SESSION_RESUME` is on (project, else organization, else default `true`); the bound adapter declares `resumableSessions` for the installed CLI version; the run executes in a container runner; the target is an issue mailbox. If any one is false, the request, mounts and session row are exactly what they are today. 2. **Name.** The session name is the issue branch name `{BRANCH_PREFIX}issue-{iid}-{slug}`, frozen in `mailboxes.branch_name` at the first run. Analysis runs use it too, even though the branch does not exist yet. When the CLI supports a human-readable session label, the adapter sets it to this name. 3. **Native id.** The adapter maps the name to the CLI's native session id and reports it in `LlmResult::$sessionId`. The engine stores it in `llm_sessions.native_session_id`. On the next run it reads the id back from the database only (latest row of the same mailbox and same `adapter_id` with a non-null id), never from the session state directory. 4. **Resume or fresh.** - Prior id for the same adapter: the CLI starts in resume mode. - No prior id: a fresh session starts under the name, with `resume_fallback = no_prior_session`. - Prior id only for another adapter: a fresh session starts, with `adapter_changed`. - A change of provider or model with the same adapter still resumes. 5. **Silent fallback.** If a resume fails in a way the adapter recognises (`state_missing`, `unsupported_version`, `refused`), the adapter runs the CLI once more in a fresh session under the same name, within the same run and the remaining timeout. The fallback never fails the run, never counts as an LLM retry, and changes no status, counter, label or comment. 6. **Prompt unchanged.** The prompt still carries the full phase context. 7. **State storage.** Session state lives in `{base}/.agent-sessions/{organization}-{project}-issue-{iid}/{adapter_id}/`, mounted at the adapter's `sessionStatePath`. It is removed at mailbox cleanup and left untouched when the setting is turned off. 8. **Visibility.** The run screen shows the session name, the native session id and whether the run resumed. ### Acceptance criteria - [ ] `AGENT_SESSION_RESUME` setting (default `true`, organization and project override). - [ ] Adapter capability `resumableSessions` and `sessionStatePath`, declared per local adapter (Claude Code: `--session-id <uuid>` for a fresh session and `--resume <id>` to resume; Codex, OpenCode, Copilot CLI and Grok Build each according to their CLI). The capability is lowered by runtime detection when the installed version falls outside the verified range. - [ ] `llm_sessions` gains `session_name`, `native_session_id`, `resumed` and `resume_fallback`. They stay null or false whenever the feature does not apply. - [ ] Two consecutive runs of the same issue mailbox with the same adapter: the second one resumes the native id recorded by the first. - [ ] First run → fresh session, `no_prior_session`. Adapter changed between phases → fresh session, `adapter_changed`. Provider or model change → resumes. - [ ] A refused or failed resume falls back to one fresh session in the same run, with the reason recorded, and no failure, retry, label or comment. - [ ] API mode, an adapter without the capability, the host runner, MR mailboxes and the setting off all produce exactly today's request and mounts. - [ ] The session state directory is mounted per mailbox and adapter and is removed at mailbox cleanup. - [ ] The run screen shows the session name, the native id and the resumed flag. - [ ] Unit and feature tests cover the cases above (`FakeLlmAdapter` gains `resumableSessions`, reports a native id and can simulate a refused resume). Playwright e2e covers the run screen fields. All of them run on both GitLab CI and Forgejo/GitHub Actions. <!-- agent:key=b6cf061edcd7:kind=description --> <!-- agent:section:end -->
Owner

Thanks @SebastienDenooz, I am on it. I will post my analysis here.

Thanks @SebastienDenooz, I am on it. I will post my analysis here. <!-- agent:key=dd79a34d8acf:kind=acknowledgement -->
Owner

Plan

Implementation plan: named agent sessions and resume (§16.5)

Current state

  • Nothing from §16.5 exists in the code yet. LlmRequest, LlmResult, LlmCapabilities and Detection have no session fields. llm_sessions and agent_installations have no session columns. There is no AGENT_SESSION_RESUME setting.
  • RunExecutor::createWorktree() (app/Runs/Execution/RunExecutor.php:308) sets mailboxes.branch_name only for read-write phases. A read-only first run (analysis) therefore gets no name. §7.3 asks for the name to be fixed at the first run, whatever its phase, while the branch itself is only created from in_development onward.
  • Containers already get HOME=/home/agent as a tmpfs. Claude Code writes its transcript there today, and it is lost when the container is removed.

Design

1. Data contracts (app/Llm/Data)

  • New SessionRef (final readonly): name, ?resumeId, stateDir (host path), ?freshReason (no_prior_session | adapter_changed, null when resumeId is set).
  • New enum App\Enums\ResumeFallback: NoPriorSession, AdapterChanged, StateMissing, UnsupportedVersion, Refused.
  • LlmRequest: add ?SessionRef $session = null as the last argument, so existing calls (SmokeTest, the tests) don't change.
  • LlmResult: add ?string $sessionId = null, bool $resumed = false, ?ResumeFallback $resumeFallback = null.
  • LlmCapabilities: add bool $resumableSessions = false and ?string $sessionStatePath = null. The constructor throws if resumableSessions is true and the path is null or not under SandboxSpec::HOME.
  • Detection: add bool $resumableSessions = false.

2. Setting

  • SettingsCatalogue: $this->add('AGENT_SESSION_RESUME', SettingType::Bool, true, $tenant); (organization and project scope, like REQUIRE_PLAN_VALIDATION).
  • Add the label and help text wherever the catalogue entries are translated or described.

3. Detection and the installation capability

  • config/alfred.php: add agents.{id}.resume_versions per adapter, the range verified for non-interactive resume. Empty means never.
  • LocalAgentAdapter::inspect() reports resumableSessions = capabilities()->resumableSessions && Semver::satisfies(version, resume_versions). It is always false for a host runner.
  • Migration add_resumable_sessions_to_agent_installations_table: resumable_sessions boolean, default false.
  • AgentDetector::detectIn() stores it. Show it on the agents screen next to the version.

4. Adapters

  • LocalAgentAdapter::invoke():
    • With no SessionRef, the code path stays exactly as today.
    • With a SessionRef, it records the start time (hrtime) and builds the command through a new hook, command(..., ?SessionMode $mode) (Fresh(id|null) or Resume(id)).
    • If a resume run fails, a new abstract method resumeFailure(ExecResult): ?ResumeFallback classifies the output. It returns null when the cause is ambiguous (§16.5 trap), and the failure then keeps its usual Outcome.
    • On a recognised failure, it re-runs once as Fresh with the remaining timeout (timeoutMs - elapsed). If no time is left, the result is Timeout.
    • A new hook, sessionId(ExecResult, SessionMode): ?string, reads the native id from the output and falls back to the id the adapter chose.
    • The prompt is the same in every mode.
  • Claude Code:
    • resumableSessions: true, sessionStatePath: /home/agent/.claude/projects.
    • Fresh session: --session-id <random uuid v4>. It must be random, not derived from the name, so that a fallback after corrupted state never collides with an existing id.
    • Resume: --resume <id>.
    • The id is read from session_id in the JSON output.
    • resumeFailure matches "No conversation found with session ID…" → StateMissing.
    • A label is passed only if the installed version has a verified flag for it; otherwise the name stays on the engine side.
    • HOST_OPTIONS (--no-session-persistence) are unchanged.
  • Codex:
    • sessionStatePath: /home/agent/.codex/sessions. This keeps auth.json and config.toml out of the persisted directory.
    • Fresh session: codex exec --json, id taken from the thread.started / session event.
    • Resume: codex exec resume <id>.
  • OpenCode (--session <id>, id from the JSON output) and Copilot CLI (--resume <id>): declared with their own sessionStatePath (sessions subdirectory only) and recognition patterns. Their resume_versions ranges must be checked against the CLIs during implementation. If either stores credentials next to its sessions, it stays false.
  • Grok Build: resumableSessions: false; resume_versions empty.
  • ApiLlmAdapter: unchanged (defaults to false).
  • FakeLlmAdapter:
    • withResumableSessions(string $path = '/home/agent/.fake/sessions').
    • It reports a native id: the incoming resumeId, or a new uuid for a fresh session.
    • refuseNextResume(ResumeFallback $reason = Refused) simulates the in-adapter fallback and returns resumed=false with that reason.
    • Its requests stay recorded, so tests can assert on $request->session.

5. Engine (RunExecutor plus a new class)

  • New class App\Runs\Execution\SessionResolver with resolve(Run, Mailbox, Project, LlmAdapter, AgentInstallation?, SandboxSpec): ?SessionRef. It returns null unless all of these hold:
    • the target is an issue mailbox;
    • sandbox->kind === RunnerKind::Image;
    • the adapter is local;
    • capabilities()->resumableSessions;
    • installation->resumable_sessions;
    • settings->bool('AGENT_SESSION_RESUME', org, project);
    • sessionStatePath does not overlap a credential mount (see Risks).
  • Prior id lookup (database only):
    • Take the latest llm_sessions row joined to runs with the same mailbox_id, the same adapter_id and a non-null native_session_id, excluding the current run.
    • If no such row exists but there is a non-null native_session_id for another adapter on the same mailbox, the reason is adapter_changed; otherwise it is no_prior_session.
    • Provider and model are not part of the key.
  • State directory:
    • Path: {repos_base_dir}/.agent-sessions/{org-slug}-{project-id}-issue-{iid}/{adapter_id}, following the GitRepository::worktreePath() convention.
    • Created with mode 0700, owned by the worker.
    • Path building goes in a small AgentSessionStore class (pathFor, ensure, removeMailbox), shared with the cleanup.
  • Name freezing in createWorktree():
    • Set branch_name on the first run of an issue mailbox, whatever the phase.
    • The worktree uses branch_name for read-write phases, or for read-only phases when origin/{branch} exists (new GitRepository::remoteBranchExists()). Otherwise it uses the temporary alfred/run-{id}.
    • This keeps "branch created only from in_development" true and stops analysis from leaving a local branch behind.
  • Sandbox:
    • SandboxSpecFactory::forRun() takes ?SessionRef (or the host directory and the container path) and fills a new SandboxSpec::$sessionMount ([host => container], read-write even in read-only phases).
    • It also adds a tmpfs for the parent of sessionStatePath, with uid=,gid=,mode=0700, so the CLI can still write the rest of its home directory (see Risks).
    • CliContainerRuntime::createArguments() adds --volume host:container:rw.
    • FakeContainerRuntime records it.
  • RunExecutor::steps():
    • Order: resolve the sandbox, then the session, then rebuild the spec with the mount (or resolve the session before forRun).
    • Store session_name before the invocation.
    • Pass the SessionRef in LlmRequest.
  • recordResult():
    • native_session_id = result->sessionId, resumed = result->resumed, resume_fallback = result->resumeFallback ?? ref->freshReason.
    • These are written only when a SessionRef was sent. They are also written when the outcome is a failure, so a timed-out session can still be resumed next time.
    • A fallback never changes the outcome, the retry decision or llm_retries: it lives entirely inside the adapter.
  • MailboxCleanup: call AgentSessionStore::removeMailbox() inside the existing try block. The setting being off leaves the directories alone.

6. Data model

  • Migration add_session_columns_to_llm_sessions_table: session_name string nullable, native_session_id string nullable, resumed boolean default false, resume_fallback string nullable.
  • Add an index on runs(mailbox_id) only if it is missing.
  • LlmSession model: add casts (ResumeFallback enum, bool).

7. Run screen

  • RunController::data() (context tab): add session_name, native_session_id, resumed, resume_fallback to the selected columns.
  • resources/views/runs/partials/tab-context.blade.php: show a Session row only when session_name is set: the name, then the native id in <code>, then "resumed" or "fresh (reason)". Each part gets a data-test hook.

Tests

Unit

  • SessionResolverTest: each condition off returns null (API, adapter capability off, installation flag off, host runner, MR mailbox, setting off at organization scope, setting off at project scope, credential overlap). Prior id for the same adapter gives resume. No prior id gives no_prior_session. A prior id only for another adapter gives adapter_changed. A provider or model change still resumes.
  • ClaudeCodeAdapterTest, CodexAdapterTest, OpenCodeAdapterTest and CopilotCliAdapterTest: exact argv for fresh, resume and no session (the last one identical to today's snapshot); native id parsing; resumeFailure patterns; fallback re-exec with the remaining timeout; an ambiguous failure gives no fallback; host runs never get session options.
  • LocalAgentAdapter detection: resume_versions lowers the capability; host detection always gives false.
  • SandboxSpecFactory and CliContainerRuntime: the session mount is read-write in read-only phases and absent without a SessionRef; the parent tmpfs options are present.
  • LlmCapabilities rejects resumableSessions without a path.

Feature (FakeLlmAdapter plus FakeContainerRuntime)

  • Two consecutive runs of the same mailbox: the second request has resumeId equal to the first run's native_session_id, and the row has resumed=true.
  • The first run is fresh with no_prior_session. An adapter change between analysis and development gives adapter_changed. Changing the binding's model still resumes.
  • A refused resume: the run succeeds, resume_fallback=refused, llm_retries is unchanged, no forge comment, no label change.
  • API mode, the host runner, MR mailboxes, the setting off, and an adapter without the capability: the request has a null session, no extra mount, and the session columns are null or false.
  • An analysis run freezes branch_name without creating the branch (temporary worktree branch, nothing pushed).
  • MailboxCleanup removes the state directory. Turning the setting off keeps it.
  • AgentDetector stores resumable_sessions.

Browser (Pest + Playwright, tests/Browser/Runs/RunsTest.php)

  • The run screen shows the session name, the native id and "resumed" for a resumed session, and the fresh reason for a fresh one.
  • The row is hidden for an API-mode run.

CI: php artisan test already runs unit, feature and browser tests in .gitlab-ci.yml, .github/workflows/ci.yml and .forgejo/workflows/ci.yml. No pipeline change is needed beyond checking that the new tests run in all three.

Risks and decisions

  • Overlap with AGENT_CREDENTIAL_MOUNTS: the documented example claude-code=/srv/claude:/home/agent/.claude contains .claude/projects. Docker would create the mountpoint inside the read-only host credential directory. Decision: if sessionStatePath equals, contains or sits under a credential mount, resume does not apply to that run. A warning is logged and the agents screen shows the reason. Credentials are never weakened.
  • Mountpoint ownership: Docker creates the intermediate directories of a bind mount (for example /home/agent/.claude) as root inside the home tmpfs, which would stop the CLI from writing its other files. The parent tmpfs owned by the worker uid:gid prevents this. This needs to be checked once with a real Docker runner and a real claude binary, beyond the fakes.
  • Exact CLI flags and error messages change between versions: everything stays in each adapter, gated by resume_versions. Any unverified adapter stays false.
  • Fallback timing: the second exec gets only the remaining timeout. If almost nothing is left, the run times out as it would today.
  • CleanDeletedBranch resets branch_name: a later run gets a newly computed name. Resume is unaffected, because the prior id is looked up by mailbox and adapter, not by name.
  • Content planted in the session state by a run: accepted by §26 and mitigated by the per-mailbox scope, the sessions-only directory, ids read from the database only, and removal at cleanup.

Commits (Conventional Commits)

  1. feat(llm): add session contracts, capability and detection of resumable sessions
  2. feat(settings): add AGENT_SESSION_RESUME
  3. feat(runs): resolve, mount and record named agent sessions
  4. feat(llm): resume Claude Code, Codex, OpenCode and Copilot CLI sessions with silent fallback
  5. feat(mailboxes): remove agent session state at cleanup
  6. feat(runs): show the agent session on the run screen
  7. test: cover session resume across adapters, runs and the run screen

The implementation plan is ready for validation. Add the ai::validate_plan label to let me start.

## Plan # Implementation plan: named agent sessions and resume (§16.5) ## Current state - Nothing from §16.5 exists in the code yet. `LlmRequest`, `LlmResult`, `LlmCapabilities` and `Detection` have no session fields. `llm_sessions` and `agent_installations` have no session columns. There is no `AGENT_SESSION_RESUME` setting. - `RunExecutor::createWorktree()` (`app/Runs/Execution/RunExecutor.php:308`) sets `mailboxes.branch_name` only for read-write phases. A read-only first run (analysis) therefore gets no name. §7.3 asks for the name to be fixed at the first run, whatever its phase, while the branch itself is only created from `in_development` onward. - Containers already get `HOME=/home/agent` as a tmpfs. Claude Code writes its transcript there today, and it is lost when the container is removed. ## Design ### 1. Data contracts (`app/Llm/Data`) - **New `SessionRef`** (final readonly): `name`, `?resumeId`, `stateDir` (host path), `?freshReason` (`no_prior_session` | `adapter_changed`, null when `resumeId` is set). - **New enum `App\Enums\ResumeFallback`**: `NoPriorSession`, `AdapterChanged`, `StateMissing`, `UnsupportedVersion`, `Refused`. - **`LlmRequest`**: add `?SessionRef $session = null` as the last argument, so existing calls (`SmokeTest`, the tests) don't change. - **`LlmResult`**: add `?string $sessionId = null`, `bool $resumed = false`, `?ResumeFallback $resumeFallback = null`. - **`LlmCapabilities`**: add `bool $resumableSessions = false` and `?string $sessionStatePath = null`. The constructor throws if `resumableSessions` is true and the path is null or not under `SandboxSpec::HOME`. - **`Detection`**: add `bool $resumableSessions = false`. ### 2. Setting - `SettingsCatalogue`: `$this->add('AGENT_SESSION_RESUME', SettingType::Bool, true, $tenant);` (organization and project scope, like `REQUIRE_PLAN_VALIDATION`). - Add the label and help text wherever the catalogue entries are translated or described. ### 3. Detection and the installation capability - `config/alfred.php`: add `agents.{id}.resume_versions` per adapter, the range verified for non-interactive resume. Empty means never. - `LocalAgentAdapter::inspect()` reports `resumableSessions = capabilities()->resumableSessions && Semver::satisfies(version, resume_versions)`. It is always false for a host runner. - Migration `add_resumable_sessions_to_agent_installations_table`: `resumable_sessions` boolean, default false. - `AgentDetector::detectIn()` stores it. Show it on the agents screen next to the version. ### 4. Adapters - **`LocalAgentAdapter::invoke()`**: - With no `SessionRef`, the code path stays exactly as today. - With a `SessionRef`, it records the start time (`hrtime`) and builds the command through a new hook, `command(..., ?SessionMode $mode)` (`Fresh(id|null)` or `Resume(id)`). - If a resume run fails, a new abstract method `resumeFailure(ExecResult): ?ResumeFallback` classifies the output. It returns null when the cause is ambiguous (§16.5 trap), and the failure then keeps its usual `Outcome`. - On a recognised failure, it re-runs once as `Fresh` with the remaining timeout (`timeoutMs - elapsed`). If no time is left, the result is `Timeout`. - A new hook, `sessionId(ExecResult, SessionMode): ?string`, reads the native id from the output and falls back to the id the adapter chose. - The prompt is the same in every mode. - **Claude Code**: - `resumableSessions: true`, `sessionStatePath: /home/agent/.claude/projects`. - Fresh session: `--session-id <random uuid v4>`. It must be random, not derived from the name, so that a fallback after corrupted state never collides with an existing id. - Resume: `--resume <id>`. - The id is read from `session_id` in the JSON output. - `resumeFailure` matches "No conversation found with session ID…" → `StateMissing`. - A label is passed only if the installed version has a verified flag for it; otherwise the name stays on the engine side. - `HOST_OPTIONS` (`--no-session-persistence`) are unchanged. - **Codex**: - `sessionStatePath: /home/agent/.codex/sessions`. This keeps `auth.json` and `config.toml` out of the persisted directory. - Fresh session: `codex exec --json`, id taken from the `thread.started` / session event. - Resume: `codex exec resume <id>`. - **OpenCode** (`--session <id>`, id from the JSON output) and **Copilot CLI** (`--resume <id>`): declared with their own `sessionStatePath` (sessions subdirectory only) and recognition patterns. Their `resume_versions` ranges must be checked against the CLIs during implementation. If either stores credentials next to its sessions, it stays `false`. - **Grok Build**: `resumableSessions: false`; `resume_versions` empty. - **`ApiLlmAdapter`**: unchanged (defaults to false). - **`FakeLlmAdapter`**: - `withResumableSessions(string $path = '/home/agent/.fake/sessions')`. - It reports a native id: the incoming `resumeId`, or a new uuid for a fresh session. - `refuseNextResume(ResumeFallback $reason = Refused)` simulates the in-adapter fallback and returns `resumed=false` with that reason. - Its requests stay recorded, so tests can assert on `$request->session`. ### 5. Engine (`RunExecutor` plus a new class) - **New class `App\Runs\Execution\SessionResolver`** with `resolve(Run, Mailbox, Project, LlmAdapter, AgentInstallation?, SandboxSpec): ?SessionRef`. It returns null unless all of these hold: - the target is an issue mailbox; - `sandbox->kind === RunnerKind::Image`; - the adapter is local; - `capabilities()->resumableSessions`; - `installation->resumable_sessions`; - `settings->bool('AGENT_SESSION_RESUME', org, project)`; - `sessionStatePath` does not overlap a credential mount (see Risks). - **Prior id lookup** (database only): - Take the latest `llm_sessions` row joined to `runs` with the same `mailbox_id`, the same `adapter_id` and a non-null `native_session_id`, excluding the current run. - If no such row exists but there is a non-null `native_session_id` for another adapter on the same mailbox, the reason is `adapter_changed`; otherwise it is `no_prior_session`. - Provider and model are not part of the key. - **State directory**: - Path: `{repos_base_dir}/.agent-sessions/{org-slug}-{project-id}-issue-{iid}/{adapter_id}`, following the `GitRepository::worktreePath()` convention. - Created with mode 0700, owned by the worker. - Path building goes in a small `AgentSessionStore` class (`pathFor`, `ensure`, `removeMailbox`), shared with the cleanup. - **Name freezing in `createWorktree()`**: - Set `branch_name` on the first run of an issue mailbox, whatever the phase. - The worktree uses `branch_name` for read-write phases, or for read-only phases when `origin/{branch}` exists (new `GitRepository::remoteBranchExists()`). Otherwise it uses the temporary `alfred/run-{id}`. - This keeps "branch created only from `in_development`" true and stops analysis from leaving a local branch behind. - **Sandbox**: - `SandboxSpecFactory::forRun()` takes `?SessionRef` (or the host directory and the container path) and fills a new `SandboxSpec::$sessionMount` (`[host => container]`, read-write even in read-only phases). - It also adds a tmpfs for the parent of `sessionStatePath`, with `uid=,gid=,mode=0700`, so the CLI can still write the rest of its home directory (see Risks). - `CliContainerRuntime::createArguments()` adds `--volume host:container:rw`. - `FakeContainerRuntime` records it. - **`RunExecutor::steps()`**: - Order: resolve the sandbox, then the session, then rebuild the spec with the mount (or resolve the session before `forRun`). - Store `session_name` before the invocation. - Pass the `SessionRef` in `LlmRequest`. - **`recordResult()`**: - `native_session_id = result->sessionId`, `resumed = result->resumed`, `resume_fallback = result->resumeFallback ?? ref->freshReason`. - These are written only when a `SessionRef` was sent. They are also written when the outcome is a failure, so a timed-out session can still be resumed next time. - A fallback never changes the outcome, the retry decision or `llm_retries`: it lives entirely inside the adapter. - **`MailboxCleanup`**: call `AgentSessionStore::removeMailbox()` inside the existing try block. The setting being off leaves the directories alone. ### 6. Data model - Migration `add_session_columns_to_llm_sessions_table`: `session_name` string nullable, `native_session_id` string nullable, `resumed` boolean default false, `resume_fallback` string nullable. - Add an index on `runs(mailbox_id)` only if it is missing. - `LlmSession` model: add casts (`ResumeFallback` enum, bool). ### 7. Run screen - `RunController::data()` (context tab): add `session_name`, `native_session_id`, `resumed`, `resume_fallback` to the selected columns. - `resources/views/runs/partials/tab-context.blade.php`: show a **Session** row only when `session_name` is set: the name, then the native id in `<code>`, then "resumed" or "fresh (reason)". Each part gets a `data-test` hook. ## Tests **Unit** - `SessionResolverTest`: each condition off returns null (API, adapter capability off, installation flag off, host runner, MR mailbox, setting off at organization scope, setting off at project scope, credential overlap). Prior id for the same adapter gives resume. No prior id gives `no_prior_session`. A prior id only for another adapter gives `adapter_changed`. A provider or model change still resumes. - `ClaudeCodeAdapterTest`, `CodexAdapterTest`, `OpenCodeAdapterTest` and `CopilotCliAdapterTest`: exact argv for fresh, resume and no session (the last one identical to today's snapshot); native id parsing; `resumeFailure` patterns; fallback re-exec with the remaining timeout; an ambiguous failure gives no fallback; host runs never get session options. - `LocalAgentAdapter` detection: `resume_versions` lowers the capability; host detection always gives false. - `SandboxSpecFactory` and `CliContainerRuntime`: the session mount is read-write in read-only phases and absent without a `SessionRef`; the parent tmpfs options are present. - `LlmCapabilities` rejects `resumableSessions` without a path. **Feature** (`FakeLlmAdapter` plus `FakeContainerRuntime`) - Two consecutive runs of the same mailbox: the second request has `resumeId` equal to the first run's `native_session_id`, and the row has `resumed=true`. - The first run is fresh with `no_prior_session`. An adapter change between analysis and development gives `adapter_changed`. Changing the binding's model still resumes. - A refused resume: the run succeeds, `resume_fallback=refused`, `llm_retries` is unchanged, no forge comment, no label change. - API mode, the host runner, MR mailboxes, the setting off, and an adapter without the capability: the request has a null session, no extra mount, and the session columns are null or false. - An analysis run freezes `branch_name` without creating the branch (temporary worktree branch, nothing pushed). - `MailboxCleanup` removes the state directory. Turning the setting off keeps it. - `AgentDetector` stores `resumable_sessions`. **Browser (Pest + Playwright, `tests/Browser/Runs/RunsTest.php`)** - The run screen shows the session name, the native id and "resumed" for a resumed session, and the fresh reason for a fresh one. - The row is hidden for an API-mode run. **CI**: `php artisan test` already runs unit, feature and browser tests in `.gitlab-ci.yml`, `.github/workflows/ci.yml` and `.forgejo/workflows/ci.yml`. No pipeline change is needed beyond checking that the new tests run in all three. ## Risks and decisions - **Overlap with `AGENT_CREDENTIAL_MOUNTS`**: the documented example `claude-code=/srv/claude:/home/agent/.claude` contains `.claude/projects`. Docker would create the mountpoint inside the read-only host credential directory. Decision: if `sessionStatePath` equals, contains or sits under a credential mount, resume does not apply to that run. A warning is logged and the agents screen shows the reason. Credentials are never weakened. - **Mountpoint ownership**: Docker creates the intermediate directories of a bind mount (for example `/home/agent/.claude`) as root inside the home tmpfs, which would stop the CLI from writing its other files. The parent tmpfs owned by the worker uid:gid prevents this. This needs to be checked once with a real Docker runner and a real `claude` binary, beyond the fakes. - **Exact CLI flags and error messages change between versions**: everything stays in each adapter, gated by `resume_versions`. Any unverified adapter stays `false`. - **Fallback timing**: the second exec gets only the remaining timeout. If almost nothing is left, the run times out as it would today. - **`CleanDeletedBranch` resets `branch_name`**: a later run gets a newly computed name. Resume is unaffected, because the prior id is looked up by mailbox and adapter, not by name. - **Content planted in the session state by a run**: accepted by §26 and mitigated by the per-mailbox scope, the sessions-only directory, ids read from the database only, and removal at cleanup. ## Commits (Conventional Commits) 1. `feat(llm): add session contracts, capability and detection of resumable sessions` 2. `feat(settings): add AGENT_SESSION_RESUME` 3. `feat(runs): resolve, mount and record named agent sessions` 4. `feat(llm): resume Claude Code, Codex, OpenCode and Copilot CLI sessions with silent fallback` 5. `feat(mailboxes): remove agent session state at cleanup` 6. `feat(runs): show the agent session on the run screen` 7. `test: cover session resume across adapters, runs and the run screen` The implementation plan is ready for validation. Add the ai::validate_plan label to let me start. <!-- agent:key=9666b96da250:kind=plan -->
Owner

Plan validated by @SebastienDenooz. Starting the development.

Plan validated by @SebastienDenooz. Starting the development. <!-- agent:key=130010730953:kind=plan_validated -->
Owner

The status label status::in_development is managed by me: I restored it. Use the command labels to steer my work.

The status label status::in_development is managed by me: I restored it. Use the command labels to steer my work. <!-- agent:key=b6535d6180fb:kind=status_label_restored -->
Owner

The status label status::in_development is managed by me: I restored it. Use the command labels to steer my work.

The status label status::in_development is managed by me: I restored it. Use the command labels to steer my work. <!-- agent:key=e8fe0a2908df:kind=status_label_restored -->
Owner

Retrying development.

Retrying `development`. <!-- agent:key=929e662e3248:kind=retry_resumed -->
Owner

Retrying development.

Retrying `development`. <!-- agent:key=0ad1a7cd2a5c:kind=retry_resumed -->
Owner

Retrying development.

Retrying `development`. <!-- agent:key=e66d650d57bd:kind=retry_resumed -->
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Alchemic.Studio/Alfred#7
No description provided.