DEV Community

Cover image for Codex CLI로 osFoundry 세션에 run 걸어두고 결과 기다리기
BBT for BBT 한국어

Posted on

Codex CLI로 osFoundry 세션에 run 걸어두고 결과 기다리기

osFoundry MCP 서버에는 사람이 버튼을 안 눌러도 작업이 시작되는 툴이 딱 하나 있다. 로그인 안 한 클라이언트한테도 tools/list는 이렇게 보여 준다.

{"name": "request_session_run",
 "description": "Queue a bounded goal for an agent, team, or loop session where this assistant is an invited runner. The osFoundry runtime executes the task when available.",
 "inputSchema": {"properties": {
     "session_type": {"enum": ["agent_session", "team_session", "loop_session"]},
     "session_id": {"type": "string"},
     "goal": {"type": "string", "maxLength": 10000},
     "max_rounds": {"type": "integer", "minimum": 1, "maximum": 10},
     "idempotency_key": {"type": "string"}},
   "required": ["session_type", "session_id", "goal", "idempotency_key"],
   "additionalProperties": false},
 "annotations": {"readOnlyHint": false, "openWorldHint": false, "destructiveHint": true},
 "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:run"]}]}
Enter fullscreen mode Exit fullscreen mode

설명문의 두 표현이 클라이언트 구조를 거의 다 정해 버린다. "invited runner"는 세 군데로 나뉜 권한 체크다. "when available"은 작업이 시작되기 전에 호출이 먼저 돌아온다는 뜻이고, 결과는 다시 가지러 가야 한다. 이 글에서는 이 둘이랑, run 다음에 세션과 주고받는 메일함까지 차례로 본다. 이 Mac에는 codex 바이너리가 없어서 클라이언트 쪽은 Python 스크립트로 흉내 내서 직접 돌렸다.

로그인 전에 뭘 알 수 있을까

생각보다 많다. initialize, tools/list, protected-resource 메타데이터는 토큰 없이 답한다.

$ uv run --with tiktoken --with jsonschema python3 runner_sim.py
== Part 1: live wire, 10 calls each, ms ==
initialize             HTTP 200  median  375.1  p90  726.0
tools/list             HTTP 200  median  551.1  p90  755.5
protected-resource     HTTP 200  median  325.4  p90  578.8
tools/call (no token)  HTTP 401  median  454.2  p90  492.8
scopes_supported: ['osfoundry:read', 'osfoundry:write', 'osfoundry:run', 'osfoundry:models']
Enter fullscreen mode Exit fullscreen mode

일본에서 Apple M1으로 잰 값이라 ms 숫자는 서버보다 경로 얘기에 가깝다. 봐야 할 건 osfoundry:run이 osfoundry:write와 별개 스코프로 공개돼 있다는 점이다. Codex 설정의 scopes에서 이걸 빼 두면 나중에 무슨 자리를 받든 run을 걸 수 있는 토큰은 안 나온다.

Runner로 쓰는 툴은 59개 중 read_session_state, request_session_run, read_session_task_result, post_session_note, read_session_mail 다섯 개다. 토큰으로 치면 전체 10,197 중 821(8.1%). cl100k_base로 센 거라 Codex 토크나이저랑은 좀 다를 수 있다. enabled_tools를 이 다섯 개로 줄이면 나머지 54개는 프롬프트에 안 올라간다.

Runner는 누가 정할까

셋이 정하고, 호출하려면 셋 다 통과해야 한다.

  1. 토큰. request_session_run은 osfoundry:run을 요구한다. 로그인 화면에서는 "Run sessions where you grant a runner role"로 보인다.
  2. 그랜트. osFoundry 웹 클라이언트(ExternalAssistantsPanel.tsx)에서 세션 그랜트는 액션을 가진다. 표시는 View, Reply, Edit, "Start work". 그랜트 행의 역할 선택은 write가 있을 때만 Contributor를, run이 있을 때만 Runner를 보여 준다.
  3. 자리. 오너가 세션마다 역할을 저장한다. 클라이언트는 POST /external-assistants/{connection}/sessions/{session_type}/{session_id}에 {"role": "observer" | "contributor" | "runner"}를 보낸다. 드롭다운에는 Viewer, Contributor, Runner로 나온다.

스크립트에서 이 체크를 다시 만들었다. 역할과 툴의 대응은 패널 설명문("Contributor to post notes and propose handoffs, or Runner to also request runs")에서, 툴별 스코프는 라이브 스키마에서 가져왔다. osFoundry 서버 코드는 공개돼 있지 않으니 이건 규칙을 재현한 거지, 서버의 거절 응답을 캡처한 게 아니다.

grant actions ['read']                    -> roles offered ['observer']
grant actions ['read', 'write']           -> roles offered ['observer', 'contributor']
grant actions ['read', 'write', 'run']    -> roles offered ['observer', 'contributor', 'runner']
tool                      observer   contributor  runner
read_session_state        yes        yes          yes
request_session_run       -          -            yes
read_session_task_result  yes        yes          yes
post_session_note         -          yes          yes
read_session_mail         yes        yes          yes
request_session_run       seat=contributor  scopes=read+run+write  -> seat 'contributor' cannot call it
request_session_run       seat=runner       scopes=read+write      -> token lacks osfoundry:run
Enter fullscreen mode Exit fullscreen mode

클라이언트 만드는 입장에선 이 구분이 중요하다. request_session_run이 거절됐을 때 고치는 방법이 둘이다. 스코프 붙여서 다시 로그인하거나(내 쪽), 오너한테 자리를 부탁하거나(상대 쪽). 재시도하지 말고 어느 쪽인지 사용자한테 알려 주는 게 맞다.

호출하고 결과 나올 때까지 무슨 일이 있을까

호출은 태스크를 큐에 넣고 바로 돌아온다. 웹 클라이언트 타입을 보면 정체는 task_type: "external_assistant"인 AgentTask다. 이 타입은 서버만 만들고, API의 태스크 생성 라우트는 안 받는다. 상태는 pending, running, input_required, completed, failed, cancelled로 바뀌고, 세션의 트리거 탭에는 "Requested by an outside assistant"로 뜬다.

누가 집어 가느냐는 세션이 어디서 도느냐에 달렸다. 오너의 데스크톱 앱에서 도는 세션이면 agent_session_runtime.rs에 타이밍이 다 적혀 있다.

  • DEFAULT_POLL_INTERVAL_MS는 15,000. 실행 시각이 된 태스크는 다음 폴링 때 리스를 잡고 가져간다.
  • 아무것도 못 찾은 폴링이 4번 이어지면(IDLE_CYCLES_BEFORE_BACKOFF) 간격이 IDLE_BACKOFF_LADDER_MS대로 30초, 60초, 300초로 늘어난다. 완전히 놀고 있는 데스크톱이면 서버가 걸어 둔 트리거가 최대 5분 기다린다는 비용이 주석에 그대로 적혀 있다. 앱에서 사람이 뭔가 하면 루프는 1초쯤 안에 깨어난다.
  • 이미 run이 돌고 있는 세션은 대기 태스크를 미룬다. 한 세션에서 run이 겹치지 않는다.
  • run이 끝나면 결과는 runtime이 보낸 세션 메일로 task_id와 함께 남는다. 제목으로 깔끔하게 끝났는지("Task … result"), 상한에 걸려 멈췄는지("goal not fully met", "stopped at your token ceiling") 알 수 있다.
  • goal은 신뢰할 수 없는 입력으로 검사된다. provenance_for_task_type이 Operator를 돌려주는 건 manual, 그러니까 오너가 앱에서 직접 시작한 run뿐이고, 이 타입을 포함한 나머지는 전부 osGuard를 거친다.

input_required는 클라이언트에서 따로 한 줄 처리해 둘 만하다. run이 osFoundry 쪽 사람한테 질문을 던지고 답을 기다리며 멈춘 상태다. 아직 실행 중이니까 계속 기다리면서, osFoundry 쪽에서 누가 답해야 한다고 사용자한테 알려 주면 된다.

폴링은 얼마나 자주 해야 할까

Codex는 툴 호출 안에서 잠들어 기다릴 수 없고, 몇 분씩 붙잡고 있는 MCP 툴 호출은 클라이언트 타임아웃에 걸린다. 그래서 현실적으로는 read_session_task_result를 폴링하게 되고, 문제는 간격이다.

스크립트 Part 4에서 가상 시계로 시뮬레이션했다. run 2,000개에 대해 집어 가기까지의 대기는 런타임의 실제 간격에서, run 길이는 중앙값 90초 로그정규분포에서 뽑았다. run 길이는 가정이니까 절대값은 참고로만 보자. 전략마다 호출 횟수랑, 완료 후 알아채기까지 늦은 시간을 셌다.

runtime active    pending wait: median   7.5 s  p90  13.5 s  max  15.0 s
  poll every 5 s            calls/task median   20  p90   41   noticed late by median   2.5 s  p90   4.5 s
  poll every 30 s           calls/task median    4  p90    7   noticed late by median  15.0 s  p90  26.8 s
  poll 5 s x1.5, cap 60 s   calls/task median    6  p90    8   noticed late by median  21.5 s  p90  48.8 s
  poll 15 s x2, cap 120 s   calls/task median    3  p90    4   noticed late by median  41.8 s  p90 107.2 s
  poll first at 60 s, then 15 s calls/task median    4  p90   11   noticed late by median   7.9 s  p90  14.6 s
runtime idle-300  pending wait: median 152.4 s  p90 273.7 s  max 300.0 s
  poll every 5 s            calls/task median   51  p90   78   noticed late by median   2.5 s  p90   4.5 s
  poll every 30 s           calls/task median    9  p90   13   noticed late by median  14.8 s  p90  26.6 s
  poll 5 s x1.5, cap 60 s   calls/task median    9  p90   11   noticed late by median  28.4 s  p90  52.9 s
  poll 15 s x2, cap 120 s   calls/task median    5  p90    6   noticed late by median  59.0 s  p90 107.7 s
  poll first at 60 s, then 15 s calls/task median   14  p90   23   noticed late by median   7.5 s  p90  13.4 s
Enter fullscreen mode Exit fullscreen mode

여기서 읽히는 건 세 가지다.

지수 백오프는 여기선 손해다. 폴링 하면 반사적으로 넣고 싶어지지만 이 run은 시간 단위가 아니라 분 단위다. "15 s x2, cap 120 s"는 고정 30초보다 호출이 한두 번 적을 뿐이고, 알아채는 건 중앙값으로 40~60초 늦다.

런타임이 깨어 있으면 평소 run 길이쯤 첫 대기를 두는 게 제일 낫다. 중앙값 4번 호출로 8초 안에 알아챈다. run이 끝날 수가 없는 시간대의 호출은 전부 헛방이다.

클라이언트 전략보다 런타임 상태가 더 크게 먹힌다. 놀고 있는 데스크톱에서는 run이 중앙값 152초 동안 pending으로 아무도 안 집어 간다. 폴링 간격을 아무리 줄여도 이건 안 줄어든다. read_session_state로 봤는데 몇 분째 pending이면 run이 막힌 게 아니라 런타임이 유휴 간격에 들어간 거다.

같은 세션에 연달아 건 run은 앞 run 뒤에 줄을 선다.

  queued run 1: starts at   13.9 s, done at  121.9 s
  queued run 2: starts at  135.9 s, done at  346.2 s
  queued run 3: starts at  357.7 s, done at  406.6 s
Enter fullscreen mode Exit fullscreen mode

Codex가 goal 세 개를 걸고 셋 다 5초마다 폴링하면, 그중 둘은 그냥 줄 서 있다는 이유로 몇 분 동안 pending만 돌려받는다.

노트로는 왜 run이 안 시작될까

post_session_note에 필요한 건 osfoundry:write랑 Contributor 자리뿐이다. 노트로 세션 에이전트를 깨워 run을 시킬 수 있다면 Contributor는 이름만 다른 Runner가 된다. 설명문이 이걸 딱 막아 둔다. "Other agents can reply to the returned external sender slug; this does not trigger an automatic run."

그러니까 노트는 메시지지 트리거가 아니다. 연결의 표시 이름으로 세션 메일에 들어가고, 웹 클라이언트의 팀 메일에서는 external_sender_name과 "outside assistant" 표시가 붙는다. 에이전트는 다음에 돌 때 답할 수 있다.

메일함은 뭘 돌려줄까

read_session_mail 설명은 "bounded notes addressed to this connection and notes it posted"이고, 뒤에 "Internal agent analysis is excluded."가 붙는다. 스키마상 한 페이지 limit은 1~20이고, 옛날 쪽은 before_id로 넘긴다.

스크립트에서 바쁜 팀 세션의 메일 409행을 모델로 만들었다. 타입은 웹 클라이언트가 아는 analysis, tool_call, tool_result, agent_note, broadcast이고, 설명대로 필터를 걸었다.

session mail rows 409; visible to the connection 22 (addressed to it 13, posted by it 9)
  not visible, by type: {'tool_call': 97, 'tool_result': 62, 'agent_note': 25, 'analysis': 176, 'broadcast': 27}
  limit 20: 2 calls to walk back through all 22 notes
  limit 10: 3 calls to walk back through all 22 notes
  limit  5: 5 calls to walk back through all 22 notes
Enter fullscreen mode Exit fullscreen mode

행 수는 모델 값이고, 중요한 건 모양이다. 연결에서 보이는 건 아주 일부라서 limit: 20이면 보통 한두 번에 다 읽힌다. run 결과도 이 일부에 안 들어간다. 데스크톱 런타임 코드에서 결과 메일은 수신자 없이 올라가서 연결 앞으로 오지 않는다. 결과는 read_session_task_result, 대화는 read_session_mail로 나눠 읽고, 한쪽으로 다른 쪽을 대신하려 하지 말자.

스키마는 어디서 막을까

라이브 inputSchema에 13개 케이스를 jsonschema(Draft 2020-12)로 돌려 봤다.

request_session_run       goal 10,000 chars              ok
request_session_run       goal 10,001 chars              REJECT maxLength: goal
request_session_run       max_rounds 10                  ok
request_session_run       max_rounds 11                  REJECT maximum: max_rounds
request_session_run       extra field 'context'          REJECT additionalProperties
request_session_run       session_type 'graph_session'   REJECT enum: session_type
read_session_task_result  no task_id                     REJECT required
read_session_mail         limit 20                       ok
read_session_mail         limit 0                        REJECT minimum: limit
read_session_mail         limit 50                       REJECT maximum: limit
post_session_note         subject 200, body 10,000       ok
post_session_note         body 10,001                    REJECT maxLength: body
post_session_note         to: '@writer'                  REJECT additionalProperties
Enter fullscreen mode Exit fullscreen mode

삽질 포인트는 두 개다. run에는 context 필드가 없어서 배경은 goal 안에 써야 하고, 10,000자 상한은 둘을 합친 거다. 노트에는 to가 없어서 특정 에이전트를 수신자로 못 정한다. 노트는 항상 세션 앞으로 가고, 에이전트는 연결의 sender slug로 답한다.

스크립트 전체는 M1에서 19.3초 걸렸다. 그중 네트워크 호출 40번이 거의 다고, 유저 CPU 시간은 0.47초였다.

직접 붙인다면

  • "작업 시작"을 독립된 OAuth 스코프로 두고, 역할은 토큰뿐 아니라 그랜트로도 거른다. 호출하는 쪽도, 오래된 그랜트도 스스로 Runner가 못 되게.
  • 태스크 ID를 붙여 바로 돌려주고, 싼 상태 조회를 준비한다. MCP 툴 호출이 에이전트 run을 기다리게 하지 않는다.
  • 런타임이 실제로 어떻게 집어 가는지(폴링 간격, 세션당 run 하나)를 문서나 상태에 드러낸다. 그래야 클라이언트가 찍지 않고 적당한 간격으로 폴링한다.
  • 외부 호출자가 보낸 건 run 자리를 갖고 있어도 신뢰할 수 없는 입력으로 검사한다.
  • 노트와 run은 떼어 둔다. 노트만 쓸 수 있는 쪽이 run을 깨우지 못하게 하고, 읽어 가는 쪽에선 세션 내부 분석을 뺀다.

세션별로 run이 어떻게 줄 서는지는 에이전트 태스크 큐, 세션을 시작하는 다른 방법은 에이전트 트리거 페이지에 있다.

Top comments (0)