chore: update edge openai handlers and reorganize task archive
This commit is contained in:
parent
ca9fcd12a9
commit
c34fec8f6b
6 changed files with 242 additions and 24 deletions
|
|
@ -42,42 +42,44 @@ task=m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforc
|
|||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [API-1] Workspace-Required Validation Helper | [ ] |
|
||||
| [API-2] Endpoint Regression Tests For Required And Non-Required Routes | [ ] |
|
||||
| [API-1] Workspace-Required Validation Helper | [x] |
|
||||
| [API-2] Endpoint Regression Tests For Required And Non-Required Routes | [x] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] predecessor `01_route_catalog_workspace_bound`의 `complete.log`가 active 또는 archive 위치에 있는지 확인한다.
|
||||
- [ ] workspace-bound route에서 workspace가 빈 값 또는 상대 경로이면 `/v1/responses`와 `/v1/chat/completions` 모두 OpenAI-compatible 400 error를 반환하게 한다.
|
||||
- [ ] workspace가 필요 없는 inference/fallback route는 기존 동작을 유지하는 회귀 test를 추가한다.
|
||||
- [ ] `go test ./apps/edge/internal/openai ./apps/edge/internal/service`와 `./scripts/e2e-openai-ollama.sh`를 실행한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
- [x] predecessor `01_route_catalog_workspace_bound`의 `complete.log`가 active 또는 archive 위치에 있는지 확인한다.
|
||||
- [x] workspace-bound route에서 workspace가 빈 값 또는 상대 경로이면 `/v1/responses`와 `/v1/chat/completions` 모두 OpenAI-compatible 400 error를 반환하게 한다.
|
||||
- [x] workspace가 필요 없는 inference/fallback route는 기존 동작을 유지하는 회귀 test를 추가한다.
|
||||
- [x] `go test ./apps/edge/internal/openai ./apps/edge/internal/service`와 `./scripts/e2e-openai-ollama.sh`를 실행한다.
|
||||
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||
|
||||
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_N.log`로 아카이브한다.
|
||||
- [ ] active `PLAN-*-G??.md`를 `plan_local_G06_M.log`로 아카이브한다.
|
||||
- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
|
||||
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
|
||||
- [ ] PASS이면 active task 디렉터리 `agent-task/m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement/`를 `agent-task/archive/YYYY/MM/m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-openai-workspace-agent-execution-contract/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_N.log`로 아카이브한다.
|
||||
- [x] active `PLAN-*-G??.md`를 `plan_local_G06_M.log`로 아카이브한다.
|
||||
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
|
||||
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
|
||||
- [x] PASS이면 active task 디렉터리 `agent-task/m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement/`를 `agent-task/archive/YYYY/MM/m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-openai-workspace-agent-execution-contract/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.md`를 작성하고 `complete.log`를 작성하지 않는다.
|
||||
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
|
||||
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
|
||||
없음. 계획의 Before/After 패턴을 그대로 따랐다.
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
|
||||
- `validateWorkspaceForRoute`를 `chat_handler.go`에 package-level 함수로 배치했다. `responses_handler.go`도 같은 패키지이므로 별도 파일 없이 재사용 가능하다.
|
||||
- `path/filepath` import를 `chat_handler.go`에만 추가했다. `responses_handler.go`는 helper를 호출만 하므로 직접 import하지 않아도 된다.
|
||||
- 테스트용 공통 config helper `workspaceBoundCfg()`를 추가해 8개 테스트 케이스의 설정 중복을 제거했다.
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
|
|
@ -110,21 +112,25 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후
|
|||
|
||||
### API-1 중간 검증
|
||||
```bash
|
||||
$ go test ./apps/edge/internal/openai
|
||||
(output)
|
||||
$ go test -count=1 ./apps/edge/internal/openai
|
||||
ok iop/apps/edge/internal/openai 1.508s
|
||||
```
|
||||
|
||||
### API-2 중간 검증
|
||||
```bash
|
||||
$ go test ./apps/edge/internal/openai ./apps/edge/internal/service
|
||||
(output)
|
||||
$ go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/service
|
||||
ok iop/apps/edge/internal/openai 1.509s
|
||||
ok iop/apps/edge/internal/service 0.005s
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```bash
|
||||
$ go test ./apps/edge/internal/openai ./apps/edge/internal/service
|
||||
$ go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/service
|
||||
ok iop/apps/edge/internal/openai 1.509s
|
||||
ok iop/apps/edge/internal/service 0.005s
|
||||
|
||||
$ ./scripts/e2e-openai-ollama.sh
|
||||
(output)
|
||||
[openai-ollama] OpenAI-compatible Ollama serving test PASSED.
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -142,3 +148,17 @@ $ ./scripts/e2e-openai-ollama.sh
|
|||
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required to proceed. |
|
||||
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus areas from the plan. |
|
||||
| 검증 결과 | Implementing agent | Paste actual stdout/stderr for the commands above. |
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
- 종합 판정: PASS
|
||||
- 차원별 평가:
|
||||
- correctness: Pass
|
||||
- completeness: Pass
|
||||
- test coverage: Pass
|
||||
- API contract: Pass
|
||||
- code quality: Pass
|
||||
- plan deviation: Pass
|
||||
- verification trust: Pass
|
||||
- 발견된 문제: 없음
|
||||
- 다음 단계: PASS - `complete.log` 작성 후 task directory를 archive로 이동한다.
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
# Complete - m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement
|
||||
|
||||
## 완료 일시
|
||||
|
||||
2026-06-13
|
||||
|
||||
## 요약
|
||||
|
||||
Workspace-required OpenAI-compatible route enforcement 작업을 1회 리뷰 루프로 검증했고 최종 판정은 PASS다.
|
||||
|
||||
## 루프 이력
|
||||
|
||||
| Plan | Review | Verdict | 메모 |
|
||||
|------|--------|---------|------|
|
||||
| `plan_local_G06_0.log` | `code_review_local_G06_0.log` | PASS | workspace-bound route의 missing/relative workspace 400 처리, non-required route 회귀, 검증 재실행이 계획 범위와 일치함 |
|
||||
|
||||
## 구현/정리 내용
|
||||
|
||||
- `routeDispatch.WorkspaceRequired`가 true인 OpenAI route에서 workspace가 비어 있거나 상대 경로이면 `/v1/responses`와 `/v1/chat/completions`가 OpenAI-compatible 400 error를 반환하도록 했다.
|
||||
- workspace 검증은 route catalog 기반 agent route에만 적용하고, non-required inference/fallback route는 workspace 없이 기존대로 실행되도록 유지했다.
|
||||
- Responses/Chat Completions handler tests에 missing, relative, absolute workspace와 non-required route 회귀 케이스를 추가했다.
|
||||
|
||||
## 최종 검증
|
||||
|
||||
- `go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/service` - PASS; `ok iop/apps/edge/internal/openai 1.509s`, `ok iop/apps/edge/internal/service 0.005s`
|
||||
- `./scripts/e2e-openai-ollama.sh` - PASS; `[openai-ollama] OpenAI-compatible Ollama serving test PASSED.`
|
||||
|
||||
## Roadmap Completion
|
||||
|
||||
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/openai-workspace-agent-execution-contract.md`
|
||||
- Completed task ids:
|
||||
- `workspace-required`: PASS; evidence=`agent-task/archive/2026/06/m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement/plan_local_G06_0.log`, `agent-task/archive/2026/06/m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement/code_review_local_G06_0.log`; verification=`go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/service`, `./scripts/e2e-openai-ollama.sh`
|
||||
- Not completed task ids: 없음
|
||||
|
||||
## 잔여 Nit
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -38,6 +39,10 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
|
|||
writeError(w, http.StatusBadRequest, "invalid_request_error", "model is required")
|
||||
return
|
||||
}
|
||||
if err := validateWorkspaceForRoute(dispatch, workspace); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
basePrompt := promptFromMessages(req.Messages)
|
||||
if strings.TrimSpace(basePrompt) == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request_error", "messages are required")
|
||||
|
|
@ -293,6 +298,19 @@ func httpStatusForRunError(err error) int {
|
|||
return http.StatusBadGateway
|
||||
}
|
||||
|
||||
func validateWorkspaceForRoute(d routeDispatch, workspace string) error {
|
||||
if !d.WorkspaceRequired {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(workspace) == "" {
|
||||
return fmt.Errorf("workspace is required for this model route")
|
||||
}
|
||||
if !filepath.IsAbs(workspace) {
|
||||
return fmt.Errorf("workspace must be an absolute path")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
|
|||
writeError(w, http.StatusBadRequest, "invalid_request_error", "model is required")
|
||||
return
|
||||
}
|
||||
if err := validateWorkspaceForRoute(dispatch, workspace); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
runMeta["source"] = "openai-responses"
|
||||
runMeta["openai_model"] = req.Model
|
||||
|
|
|
|||
|
|
@ -1167,3 +1167,138 @@ func TestCollectRunResultFailsWhenEventStreamCloses(t *testing.T) {
|
|||
t.Fatalf("expected run stream closed error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// workspaceBoundCfg returns a server config with a workspace-required codex route and a
|
||||
// non-required llama3 route for workspace validation tests.
|
||||
func workspaceBoundCfg() config.EdgeOpenAIConf {
|
||||
return config.EdgeOpenAIConf{
|
||||
Adapter: "ollama",
|
||||
Target: "llama3",
|
||||
ModelRoutes: []config.OpenAIRouteEntry{
|
||||
{Model: "codex", Adapter: "cli", Target: "codex", WorkspaceRequired: true},
|
||||
{Model: "llama3", Adapter: "ollama", Target: "llama3"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesWorkspaceRequiredRouteMissingWorkspace400(t *testing.T) {
|
||||
fake := &fakeRunService{events: make(chan *iop.RunEvent)}
|
||||
srv := NewServer(workspaceBoundCfg(), fake, nil)
|
||||
body := `{"model":"codex","input":"hello"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleResponses(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing workspace: want 400, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "workspace is required") {
|
||||
t.Fatalf("expected workspace error, got %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesWorkspaceRequiredRouteRelativeWorkspace400(t *testing.T) {
|
||||
fake := &fakeRunService{events: make(chan *iop.RunEvent)}
|
||||
srv := NewServer(workspaceBoundCfg(), fake, nil)
|
||||
body := `{"model":"codex","input":"hello","metadata":{"workspace":"relative/path"}}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleResponses(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("relative workspace: want 400, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "absolute path") {
|
||||
t.Fatalf("expected absolute path error, got %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesWorkspaceRequiredRouteAbsoluteWorkspaceOK(t *testing.T) {
|
||||
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
||||
fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
|
||||
fake.events <- &iop.RunEvent{Type: "complete"}
|
||||
srv := NewServer(workspaceBoundCfg(), fake, nil)
|
||||
body := `{"model":"codex","input":"hello","metadata":{"workspace":"/abs/path"}}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleResponses(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("absolute workspace: want 200, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if fake.req.Workspace != "/abs/path" {
|
||||
t.Fatalf("workspace not preserved: got %q", fake.req.Workspace)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesNonRequiredRouteNoWorkspaceOK(t *testing.T) {
|
||||
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
||||
fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
|
||||
fake.events <- &iop.RunEvent{Type: "complete"}
|
||||
srv := NewServer(workspaceBoundCfg(), fake, nil)
|
||||
body := `{"model":"llama3","input":"hello"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleResponses(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("non-required route no workspace: want 200, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsWorkspaceRequiredRouteMissingWorkspace400(t *testing.T) {
|
||||
fake := &fakeRunService{events: make(chan *iop.RunEvent)}
|
||||
srv := NewServer(workspaceBoundCfg(), fake, nil)
|
||||
body := `{"model":"codex","messages":[{"role":"user","content":"hi"}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleChatCompletions(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing workspace: want 400, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "workspace is required") {
|
||||
t.Fatalf("expected workspace error, got %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsWorkspaceRequiredRouteRelativeWorkspace400(t *testing.T) {
|
||||
fake := &fakeRunService{events: make(chan *iop.RunEvent)}
|
||||
srv := NewServer(workspaceBoundCfg(), fake, nil)
|
||||
body := `{"model":"codex","messages":[{"role":"user","content":"hi"}],"metadata":{"workspace":"some/relative"}}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleChatCompletions(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("relative workspace: want 400, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "absolute path") {
|
||||
t.Fatalf("expected absolute path error, got %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsWorkspaceRequiredRouteAbsoluteWorkspaceOK(t *testing.T) {
|
||||
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
||||
fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
|
||||
fake.events <- &iop.RunEvent{Type: "complete"}
|
||||
srv := NewServer(workspaceBoundCfg(), fake, nil)
|
||||
body := `{"model":"codex","messages":[{"role":"user","content":"hi"}],"metadata":{"workspace":"/abs/workspace"}}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleChatCompletions(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("absolute workspace: want 200, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if fake.req.Workspace != "/abs/workspace" {
|
||||
t.Fatalf("workspace not preserved: got %q", fake.req.Workspace)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsNonRequiredRouteNoWorkspaceOK(t *testing.T) {
|
||||
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
||||
fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
|
||||
fake.events <- &iop.RunEvent{Type: "complete"}
|
||||
srv := NewServer(workspaceBoundCfg(), fake, nil)
|
||||
body := `{"model":"llama3","messages":[{"role":"user","content":"hi"}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleChatCompletions(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("non-required route no workspace: want 200, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue