DMF_Crawler/docs/ops/01-scheduling-and-resilience.md
Yun Chan 56a6e2da93 chore: 저장소 구조 정리 및 문서화, 첫 커밋
- src/dist 산출물 분리 원칙 정리(.gitignore, .gitattributes)
- 루트 및 주요 폴더(config/scripts/prompts/tests/src, 런타임 폴더 5종)에
  안내용 README.md 추가
- CHANGELOG.md, LICENSE, docs/ops/05-release-and-versioning.md 추가
- docs/README.md 문서 지도 갱신
2026-09-04 09:25:44 +09:00

121 KiB
Raw Blame History

운영 런북 — 06:00 스케줄링 · 재부팅 내성 · 워치독

이 문서의 역할: DMF Crawler 를 Windows 11 PC 에 올려 매일 06:00 에 사람 없이 실행시키고, 재부팅·절전·Windows Update·프로세스 강제 종료를 견디게 만들고, 죽었을 때 사람이 15분 안에 알아채고 복구하게 만드는 실행 런북이다. 이 문서의 명령은 복사·붙여넣기하면 그대로 동작해야 한다. 설계 근거는 docs/design/01-architecture.md(정본)에, 스케줄러 옵션의 원문 근거는 docs/research/08-windows-scheduling-and-resilience.md에 있다. 충돌하면 아키텍처 정본이 이긴다.


0. 한눈에 보기

이 문서가 확정하는 것:

  • 실행 컨테이너는 Windows 작업 스케줄러다. Windows 서비스·WSL2 cron·Docker Desktop 은 모두 기각한다. 결정적 이유는 성능이 아니라 agy OAuth 토큰이 ~/.gemini/antigravity-cli/antigravity-oauth-token 평문 파일로 사용자 프로필 안에 있다는 사실이다 — 배치는 반드시 그 사용자 계정 컨텍스트에서 돌아야 한다.
  • 작업은 3종이다: DMF_Crawler_Daily(배치, S4U, 06:00 + 부팅), DMF_Crawler_Agent(알림·워치독, Interactive, 로그온 + 15분 반복), DMF_Crawler_AgyUpdate(주간 agy update, 일요일 14:00). 전부 \DMF_Crawler\ 폴더에 등록한다.
  • 워치독은 4번째 작업이 아니다. heartbeat 신선도 판정은 DMF_Crawler_Agent 안(watchdog.py)에서 돈다. 알림을 띄울 수 있는 세션에서 판정해야 "판정은 됐는데 화면에 못 띄운다"는 공백이 사라지기 때문이다(아키텍처 ADR-10·기각 기록).
  • 기본값이 우리를 배신하는 4개를 반드시 뒤집는다: DisallowStartIfOnBatteries(기본 true→false), StopIfGoingOnBatteries(true→false), StartWhenAvailable(false→true), WakeToRun(false→true). 손대지 않으면 노트북에서 06:00 에 안 돈다.
  • AtStartup 트리거는 보조 수단이지 안전망이 아니다. Fast Startup(빠른 시작) 때문에 "종료 → 켜기"는 실제로는 커널 세션 최대 절전 복귀라서 부팅 트리거가 안 뜬다. 진짜 캐치업 안전망은 StartWhenAvailable 이다.
  • -LogonType S4U 가 기본이지만, DPAPI 복호화가 S4U 세션에서 실패하면 즉시 Password 로 전환한다. 이 분기는 추측하지 말고 §2.8 의 프로브 절차로 실측한다. 판정이 갈리는 유일한 설치 시점 결정이다.
  • 중복 실행 방어는 3중이다: 코드 idempotency 가드(오늘 이미 SUCCESS면 종료 0) → state\run.lock 파일 락(msvcrt) → 스케줄러 MultipleInstances=IgnoreNew. 스케줄러 설정만으로는 "06:00 실행 후 재부팅 캐치업" 같은 순차 재실행을 못 막는다.
  • 한국은 DST 가 없다(KST=UTC+9 고정). 작업 트리거에 "표준 시간대에 맞춰 동기화(Synchronize across time zones)"를 켜지 않는다. DST 전환 버그의 사정권 밖이다.
  • Windows Update 자동 재시작은 활성 시간(Active hours) 05:0023:00 으로 06:00 을 보호한다. 최대 범위 18시간 제한 안에 들어간다.
  • 로그는 실행 1회당 디렉터리 하나(logs\run_<run_id>\)로 떨어진다. 조사 시작점이 "가장 최근 디렉터리를 연다" 하나로 고정된다.

1. 스케줄링 방식 확정

1.1 워크로드 성질

항목 스케줄러 선택에 주는 함의
실행 빈도 하루 1회 06:00 (KST) 상주 프로세스 불필요
실행 시간 정상 2~6분, 상한 30분(ExecutionTimeLimit) 타임아웃 여유 필요
네트워크 공개 Open API 로의 아웃바운드 HTTPS 네트워크 드라이브·UNC·도메인 인증 불필요
자격증명 ① 공공데이터포털 API 키(DPAPI 사용자 범위 암호화) ② agy OAuth 토큰(사용자 프로필 평문 파일) 사용자 프로필이 살아 있어야 한다
산출물 reports\*.xlsx, data\dmf.sqlite3 로컬 디스크 쓰기
알림 실패 시 토스트·강제 모달 데스크톱이 있는 세션이 필요
재부팅 내성 필수 부팅 트리거 + 놓친 작업 캐치업

1.2 후보 4종 평가

방식 agy 토큰 접근 부팅 내성 놓친 실행 캐치업 알림 표시 설치·형상관리 판정
작업 스케줄러 사용자 계정으로 실행 가능 BootTrigger StartWhenAvailable 내장 △ 배치는 불가 → 별도 Interactive 작업으로 분리 XML export/import 채택
Windows 서비스(NSSM/WinSW/pywin32) ⚠️ SYSTEM 이면 불가. 사용자 계정으로 돌리면 가능하나 이점 소멸 직접 구현 Session 0 격리 — UI 절대 불가 관리자 설치·제거 기각
WSL2 cron 토큰이 Windows 프로필에 있음(교차 접근 지저분) 배포판이 떠 있어야 cron 이 돈다 기각
Docker Desktop + cron 토큰·DPAPI 모두 컨테이너 밖 "Start Docker Desktop when you sign in" — 로그인 없이는 안 뜬다 기각

1.3 결정과 그 근거 세 줄

  1. agy 토큰이 사용자 프로필 평문 파일이라는 실측 사실(agy SSOT §4.2)이 SYSTEM 계정을 원천 배제한다. 서비스의 유일한 장점(SYSTEM 무인성)이 여기서는 장점이 아니라 결함이다.
  2. 하루 1회 5분짜리 배치를 위해 24시간 상주 프로세스를 띄우면 "그 프로세스는 누가 감시하나"라는 감시 대상이 하나 더 생긴다. 작업 스케줄러는 OS 가 이미 감시한다.
  3. 서비스는 Session 0 격리 때문에 요구 R7.2(강제 창)를 구조적으로 만족할 수 없다. 작업 스케줄러는 Interactive 로그온 타입 작업으로 이 문제를 정면 해결한다.

1.4 작업 3종 세트

┌──────────────────────────────────────────────────────────────────────────┐
│ ① \DMF_Crawler\DMF_Crawler_Daily            [배치 · UI 없음]             │
│   트리거  : 매일 06:00 (RandomDelay PT4M) + 시스템 시작 (Delay PT5M)      │
│   보안    : <도메인>\<사용자>, LogonType S4U(기본), RunLevel Highest      │
│   설정    : StartWhenAvailable / WakeToRun / 배터리 조건 해제 /           │
│             RestartCount 3 · RestartInterval PT10M /                     │
│             ExecutionTimeLimit PT30M / MultipleInstances IgnoreNew       │
│   액션    : .venv\Scripts\python.exe -m dmf_crawler run --trigger scheduled│
│   결과    : 성공 → state\heartbeat.json 갱신                              │
│             실패 → alerts 테이블 + state\alerts.json + 이벤트 로그        │
│             ★ 화면에는 아무것도 띄우지 않는다 (S4U 에는 데스크톱이 없다)   │
└──────────────────────────────────────────────────────────────────────────┘
                                  │ (파일·DB 를 통한 비동기 전달)
                                  ▼
┌──────────────────────────────────────────────────────────────────────────┐
│ ② \DMF_Crawler\DMF_Crawler_Agent            [알림 · 워치독 · UI 소유자]   │
│   트리거  : 로그온 시 + 15분마다 무한 반복                                │
│   보안    : 동일 사용자, LogonType Interactive, RunLevel Limited          │
│   설정    : ExecutionTimeLimit PT10M / MultipleInstances IgnoreNew        │
│   액션    : .venv\Scripts\pythonw.exe -m dmf_crawler notify-pump --once   │
│   동작    : watchdog 판정 → WARN/INFO 토스트 → CRITICAL 복구 GUI(모달)    │
└──────────────────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────────────────┐
│ ③ \DMF_Crawler\DMF_Crawler_AgyUpdate        [주간 유지보수]              │
│   트리거  : 매주 일요일 14:00                                             │
│   보안    : 동일 사용자, LogonType S4U, RunLevel Limited                  │
│   액션    : %LOCALAPPDATA%\agy\bin\agy.exe update                        │
│   이유    : 배치 중 자동 업데이트가 바이너리를 교체하면 실행이 깨진다.     │
│             배치는 AGY_CLI_DISABLE_AUTO_UPDATE=true 로 자동 업데이트를    │
│             끄고(코드에서 주입), 교체는 사람이 깨어 있는 시간에 몰아서 한다.│
└──────────────────────────────────────────────────────────────────────────┘

핵심 설계 원칙 한 줄: 알림을 발생시키는 주체(배치)와 알림을 표시하는 주체(에이전트)를 분리한다. 배치는 "알림 의도"만 남기고, 데스크톱을 가진 에이전트가 그것을 읽어 띄운다.

1.5 워치독을 별도 작업으로 만들지 않는 이유

DMF_Crawler_Agent 가 15분마다 로그온 세션에서 도는데, 그 안에서 heartbeat 판정을 함께 하면 작업이 하나 줄고 "판정한 세션이 곧 표시 가능한 세션"이라는 성질이 공짜로 따라온다. 07:00 짜리 별도 워치독 작업을 두면 판정은 S4U 세션에서 되고 표시는 다른 세션에서 되어, 두 세션 사이에 또 큐를 놓아야 한다. 기각한다.

예외: 사람이 며칠씩 로그오프해 두는 PC 라면 §5.8 의 자동 로그온 대안 또는 부록의 웹훅 확장을 검토하라. 기본 구성은 "다음 로그온 시 밀린 알림을 전부 표시"로 흡수한다(아키텍처 실패 경로 [F]).


2. 주 작업 등록 스크립트

2.1 파일 이름과 호출 관계

파일 역할
scripts\install_tasks.ps1 작업 3종 idempotent 등록. 이 절의 전문
scripts\uninstall_tasks.ps1 작업 3종 제거
python -m dmf_crawler install-task --time 06:00 --user <계정> 위 스크립트를 config.toml 값으로 호출하는 얇은 래퍼

파일명은 복수형 install_tasks.ps1 이다(아키텍처 디렉터리 트리 §2). 작업이 3개이므로 단수형은 쓰지 않는다.

2.2 설정 키 → 스크립트 파라미터 매핑

PowerShell 은 TOML 을 파싱하지 못한다. 그래서 config.toml 을 읽는 쪽은 Python CLI 이고, PS 스크립트는 파라미터만 받는다. 스크립트의 기본값은 config.toml 기본값과 정확히 같게 유지한다.

config.toml 스크립트 파라미터 기본값
schedule.daily_time -Time 06:00
schedule.jitter_seconds -JitterSeconds 240
schedule.startup_delay_minutes -StartupDelayMinutes 5
schedule.execution_time_limit_minutes -ExecutionTimeLimitMinutes 30
schedule.restart_count -RestartCount 3
schedule.restart_interval_minutes -RestartIntervalMinutes 10
schedule.agent_repeat_minutes -AgentRepeatMinutes 15
schedule.agy_update_weekday -AgyUpdateWeekday Sunday
schedule.agy_update_time -AgyUpdateTime 14:00
-User $env:USERDOMAIN\$env:USERNAME
-LogonType S4U

2.3 scripts\install_tasks.ps1 전문

#Requires -Version 5.1
<#
.SYNOPSIS
    DMF Crawler 작업 스케줄러 작업 3종(Daily / Agent / AgyUpdate)을 등록한다.

.DESCRIPTION
    멱등(idempotent)하다. 같은 이름의 작업이 이미 있으면 지우고 다시 만든다.
    관리자 권한 PowerShell 에서 실행해야 한다(RunLevel Highest 등록에 필요).

.EXAMPLE
    powershell -ExecutionPolicy Bypass -File .\scripts\install_tasks.ps1

.EXAMPLE
    # DPAPI 프로브 결과 S4U 가 실패했을 때
    powershell -ExecutionPolicy Bypass -File .\scripts\install_tasks.ps1 -LogonType Password

.EXAMPLE
    # 등록 직후 보안 컨텍스트 프로브까지 수행
    powershell -ExecutionPolicy Bypass -File .\scripts\install_tasks.ps1 -Verify
#>
[CmdletBinding()]
param(
    [string]$ProjectRoot              = (Split-Path -Parent $PSScriptRoot),
    [string]$TaskPath                 = '\DMF_Crawler\',
    [string]$Time                     = '06:00',
    [string]$User                     = "$env:USERDOMAIN\$env:USERNAME",
    [ValidateSet('S4U', 'Password', 'Interactive')]
    [string]$LogonType                = 'S4U',
    [System.Security.SecureString]$Password,
    [ValidateRange(0, 300)]
    [int]$JitterSeconds               = 240,
    [ValidateRange(0, 60)]
    [int]$StartupDelayMinutes         = 5,
    [ValidateRange(5, 720)]
    [int]$ExecutionTimeLimitMinutes   = 30,
    [ValidateRange(0, 255)]
    [int]$RestartCount                = 3,
    [ValidateRange(1, 1440)]
    [int]$RestartIntervalMinutes      = 10,
    [ValidateRange(1, 1440)]
    [int]$AgentRepeatMinutes          = 15,
    [ValidateSet('Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday')]
    [string]$AgyUpdateWeekday         = 'Sunday',
    [string]$AgyUpdateTime            = '14:00',
    [switch]$SkipAgyUpdateTask,
    [switch]$Verify
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

# ---------------------------------------------------------------- 유틸
function Write-Step { param([string]$Message) Write-Host "[install_tasks] $Message" }
function Write-Warn { param([string]$Message) Write-Host "[install_tasks] ! $Message" -ForegroundColor Yellow }
function Write-Good { param([string]$Message) Write-Host "[install_tasks] + $Message" -ForegroundColor Green }

function Assert-Administrator {
    $id = [Security.Principal.WindowsIdentity]::GetCurrent()
    $pr = [Security.Principal.WindowsPrincipal]::new($id)
    if (-not $pr.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
        throw '관리자 권한 PowerShell 에서 실행하세요. (시작 → PowerShell 우클릭 → 관리자 권한으로 실행)'
    }
}

function ConvertTo-PlainText {
    param([System.Security.SecureString]$Secure)
    if (-not $Secure) { return $null }
    $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Secure)
    try   { return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) }
    finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }
}

function Get-TimeOfDay {
    param([string]$Text, [string]$Label)
    $parsed = [datetime]::MinValue
    $ok = [datetime]::TryParseExact(
        $Text, 'HH:mm',
        [Globalization.CultureInfo]::InvariantCulture,
        [Globalization.DateTimeStyles]::None,
        [ref]$parsed)
    if (-not $ok) { throw "$Label 형식이 잘못됐습니다: '$Text' (HH:mm 이어야 합니다)" }
    return (Get-Date).Date.AddHours($parsed.Hour).AddMinutes($parsed.Minute)
}

# ---------------------------------------------------------------- 0. 사전 점검
Assert-Administrator

$ProjectRoot = (Resolve-Path -LiteralPath $ProjectRoot).Path
$PythonExe   = Join-Path $ProjectRoot '.venv\Scripts\python.exe'
$PythonwExe  = Join-Path $ProjectRoot '.venv\Scripts\pythonw.exe'
$AgyExe      = Join-Path $env:LOCALAPPDATA 'agy\bin\agy.exe'

Write-Step "프로젝트 루트 : $ProjectRoot"
Write-Step "실행 계정     : $User  (LogonType=$LogonType)"

foreach ($exe in @($PythonExe, $PythonwExe)) {
    if (-not (Test-Path -LiteralPath $exe)) {
        throw "가상환경 실행 파일이 없습니다: $exe`n  → bootstrap.cmd 를 먼저 실행하세요."
    }
}
if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot 'config\config.toml'))) {
    Write-Warn 'config\config.toml 이 없습니다. 등록은 진행하지만 첫 실행은 종료 코드 2(BLOCKED)로 끝납니다.'
}

foreach ($dir in @('state', 'logs', 'reports', 'data')) {
    $p = Join-Path $ProjectRoot $dir
    if (-not (Test-Path -LiteralPath $p)) { New-Item -ItemType Directory -Path $p | Out-Null }
}

$plainPassword = $null
if ($LogonType -eq 'Password') {
    if (-not $Password) {
        $Password = Read-Host -AsSecureString $User» 계정의 Windows 로그인 암호"
    }
    $plainPassword = ConvertTo-PlainText -Secure $Password
    if ([string]::IsNullOrEmpty($plainPassword)) { throw '암호가 비어 있습니다.' }
}

# ---------------------------------------------------------------- 1. 작업 기록(History) 채널 활성화
# 기본적으로 꺼져 있다. 꺼져 있으면 "기록" 탭이 비고 사후 진단이 불가능하다.
Write-Step '작업 스케줄러 Operational 로그 활성화'
& wevtutil.exe set-log 'Microsoft-Windows-TaskScheduler/Operational' /enabled:true /quiet
& wevtutil.exe set-log 'Microsoft-Windows-TaskScheduler/Operational' /maxsize:67108864
if ($LASTEXITCODE -ne 0) { Write-Warn "wevtutil 이 $LASTEXITCODE 로 끝났습니다. 기록 없이 진행합니다." }

# ---------------------------------------------------------------- 2. 공통 등록 함수
function Register-DmfTask {
    param(
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][string]$Path,
        [Parameter(Mandatory)]$Action,
        [Parameter(Mandatory)]$Trigger,
        [Parameter(Mandatory)]$Settings,
        [Parameter(Mandatory)]$Principal,
        [Parameter(Mandatory)][string]$Description,
        [string]$PlainPassword
    )
    $existing = Get-ScheduledTask -TaskName $Name -TaskPath $Path -ErrorAction SilentlyContinue
    if ($existing) {
        Write-Step "기존 작업 제거: $Path$Name"
        Unregister-ScheduledTask -TaskName $Name -TaskPath $Path -Confirm:$false
    }

    $definition = New-ScheduledTask `
        -Action      $Action `
        -Trigger     $Trigger `
        -Settings    $Settings `
        -Principal   $Principal `
        -Description $Description

    if ($PlainPassword) {
        Register-ScheduledTask -TaskName $Name -TaskPath $Path -InputObject $definition `
            -User $Principal.UserId -Password $PlainPassword | Out-Null
    } else {
        Register-ScheduledTask -TaskName $Name -TaskPath $Path -InputObject $definition | Out-Null
    }
    Write-Good "등록 완료: $Path$Name"
}

# ---------------------------------------------------------------- 3. ① DMF_Crawler_Daily
Write-Step '① DMF_Crawler_Daily 구성'

$dailyAt = Get-TimeOfDay -Text $Time -Label 'schedule.daily_time'

$actionDaily = New-ScheduledTaskAction `
    -Execute          $PythonExe `
    -Argument         '-m dmf_crawler run --trigger scheduled' `
    -WorkingDirectory $ProjectRoot

$trgDaily = New-ScheduledTaskTrigger -Daily -At $dailyAt `
    -RandomDelay (New-TimeSpan -Seconds $JitterSeconds)

# AtStartup 트리거에는 -Delay 파라미터가 없다. CIM 인스턴스 속성을 직접 채운다.
$trgBoot = New-ScheduledTaskTrigger -AtStartup
$trgBoot.Delay = "PT${StartupDelayMinutes}M"

$setDaily = New-ScheduledTaskSettingsSet `
    -AllowStartIfOnBatteries `
    -DontStopIfGoingOnBatteries `
    -StartWhenAvailable `
    -WakeToRun `
    -DontStopOnIdleEnd `
    -RunOnlyIfNetworkAvailable `
    -ExecutionTimeLimit (New-TimeSpan -Minutes $ExecutionTimeLimitMinutes) `
    -RestartCount       $RestartCount `
    -RestartInterval    (New-TimeSpan -Minutes $RestartIntervalMinutes) `
    -MultipleInstances  IgnoreNew `
    -Priority           5 `
    -Compatibility      Win8

$prcDaily = New-ScheduledTaskPrincipal -UserId $User -LogonType $LogonType -RunLevel Highest

Register-DmfTask `
    -Name          'DMF_Crawler_Daily' `
    -Path          $TaskPath `
    -Action        $actionDaily `
    -Trigger       @($trgDaily, $trgBoot) `
    -Settings      $setDaily `
    -Principal     $prcDaily `
    -Description   "DMF 일일 수집·비교·리포트 배치. 매일 $Time + 부팅 후 ${StartupDelayMinutes}분. UI 를 띄우지 않는다." `
    -PlainPassword $plainPassword

# ---------------------------------------------------------------- 4. ② DMF_Crawler_Agent
Write-Step '② DMF_Crawler_Agent 구성'

$actionAgent = New-ScheduledTaskAction `
    -Execute          $PythonwExe `
    -Argument         '-m dmf_crawler notify-pump --once' `
    -WorkingDirectory $ProjectRoot

# (a) 로그온 시. (b) 지금부터 15분마다 무한 반복.
$trgLogon = New-ScheduledTaskTrigger -AtLogOn -User $User

$repeatStart = (Get-Date).AddMinutes(2)
try {
    $trgRepeat = New-ScheduledTaskTrigger -Once -At $repeatStart `
        -RepetitionInterval (New-TimeSpan -Minutes $AgentRepeatMinutes) `
        -RepetitionDuration ([TimeSpan]::MaxValue)
} catch {
    # 일부 빌드에서 [TimeSpan]::MaxValue 가 거부된다. 10년으로 대체한다.
    Write-Warn 'RepetitionDuration=MaxValue 거부됨 → 3650일로 대체'
    $trgRepeat = New-ScheduledTaskTrigger -Once -At $repeatStart `
        -RepetitionInterval (New-TimeSpan -Minutes $AgentRepeatMinutes) `
        -RepetitionDuration (New-TimeSpan -Days 3650)
}
# 로그온 트리거에도 같은 반복을 붙여 둔다(로그온 이후에도 계속 돌게).
$trgLogon.Repetition = $trgRepeat.Repetition

$setAgent = New-ScheduledTaskSettingsSet `
    -AllowStartIfOnBatteries `
    -DontStopIfGoingOnBatteries `
    -DontStopOnIdleEnd `
    -ExecutionTimeLimit (New-TimeSpan -Minutes 10) `
    -MultipleInstances  IgnoreNew `
    -Priority           7 `
    -Compatibility      Win8

# Interactive 는 로그온한 세션에서만 돈다 — 그것이 목적이다(UI 를 띄우는 유일한 작업).
$prcAgent = New-ScheduledTaskPrincipal -UserId $User -LogonType Interactive -RunLevel Limited

Register-DmfTask `
    -Name        'DMF_Crawler_Agent' `
    -Path        $TaskPath `
    -Action      $actionAgent `
    -Trigger     @($trgLogon, $trgRepeat) `
    -Settings    $setAgent `
    -Principal   $prcAgent `
    -Description "DMF 알림 에이전트. 로그온 시 + ${AgentRepeatMinutes}분마다 heartbeat 를 점검하고 밀린 알림을 표시한다."

# ---------------------------------------------------------------- 5. ③ DMF_Crawler_AgyUpdate
if ($SkipAgyUpdateTask) {
    Write-Warn '③ DMF_Crawler_AgyUpdate 는 -SkipAgyUpdateTask 로 건너뜁니다.'
} elseif (-not (Test-Path -LiteralPath $AgyExe)) {
    Write-Warn "agy.exe 를 찾을 수 없어 ③ 을 건너뜁니다: $AgyExe"
    Write-Warn '  → scripts\bootstrap_agy.ps1 실행 후 이 스크립트를 다시 돌리세요.'
} else {
    Write-Step '③ DMF_Crawler_AgyUpdate 구성'

    $agyAt = Get-TimeOfDay -Text $AgyUpdateTime -Label 'schedule.agy_update_time'

    $actionAgy = New-ScheduledTaskAction `
        -Execute          $AgyExe `
        -Argument         'update' `
        -WorkingDirectory $ProjectRoot

    $trgAgy = New-ScheduledTaskTrigger -Weekly -WeeksInterval 1 `
        -DaysOfWeek $AgyUpdateWeekday -At $agyAt `
        -RandomDelay (New-TimeSpan -Minutes 10)

    $setAgy = New-ScheduledTaskSettingsSet `
        -AllowStartIfOnBatteries `
        -DontStopIfGoingOnBatteries `
        -StartWhenAvailable `
        -RunOnlyIfNetworkAvailable `
        -ExecutionTimeLimit (New-TimeSpan -Minutes 30) `
        -MultipleInstances  IgnoreNew `
        -Priority           7 `
        -Compatibility      Win8

    $prcAgy = New-ScheduledTaskPrincipal -UserId $User -LogonType $LogonType -RunLevel Limited

    Register-DmfTask `
        -Name          'DMF_Crawler_AgyUpdate' `
        -Path          $TaskPath `
        -Action        $actionAgy `
        -Trigger       $trgAgy `
        -Settings      $setAgy `
        -Principal     $prcAgy `
        -Description   "agy CLI 주간 업데이트. 배치 시간대를 피해 $AgyUpdateWeekday $AgyUpdateTime 에 돈다." `
        -PlainPassword $plainPassword
}

# ---------------------------------------------------------------- 6. 등록 결과 요약
Write-Host ''
Write-Step '등록 결과'
Get-ScheduledTask -TaskPath $TaskPath |
    Select-Object TaskName,
                  State,
                  @{ n = 'LogonType'; e = { $_.Principal.LogonType } },
                  @{ n = 'RunLevel';  e = { $_.Principal.RunLevel } },
                  @{ n = 'UserId';    e = { $_.Principal.UserId } } |
    Format-Table -AutoSize

Get-ScheduledTask -TaskPath $TaskPath | ForEach-Object {
    $info = $_ | Get-ScheduledTaskInfo
    [pscustomobject]@{
        TaskName       = $_.TaskName
        NextRunTime    = $info.NextRunTime
        LastRunTime    = $info.LastRunTime
        LastTaskResult = ('0x{0:X}' -f $info.LastTaskResult)
    }
} | Format-Table -AutoSize

# ---------------------------------------------------------------- 7. 보안 컨텍스트 프로브 (-Verify)
if ($Verify) {
    Write-Host ''
    Write-Step '보안 컨텍스트 프로브 시작 (DPAPI 복호화가 이 LogonType 에서 되는지 실측)'

    $probeName = 'DMF_Crawler_Probe'
    $probeOut  = Join-Path $ProjectRoot 'state\probe.json'
    if (Test-Path -LiteralPath $probeOut) { Remove-Item -LiteralPath $probeOut -Force }

    # doctor --json 을 파일로 리다이렉트해야 하므로 cmd.exe 를 경유한다.
    $probeCmd = '/c ""{0}" -m dmf_crawler doctor --json > "{1}" 2>&1"' -f $PythonExe, $probeOut

    $actionProbe = New-ScheduledTaskAction -Execute $env:ComSpec -Argument $probeCmd -WorkingDirectory $ProjectRoot
    $setProbe    = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
                       -ExecutionTimeLimit (New-TimeSpan -Minutes 3) -MultipleInstances IgnoreNew -Compatibility Win8
    $trgProbe    = New-ScheduledTaskTrigger -Once -At (Get-Date).AddYears(10)   # 자동 실행은 절대 안 함
    $prcProbe    = New-ScheduledTaskPrincipal -UserId $User -LogonType $LogonType -RunLevel Highest

    Register-DmfTask -Name $probeName -Path $TaskPath -Action $actionProbe -Trigger $trgProbe `
        -Settings $setProbe -Principal $prcProbe -Description '일회성 보안 컨텍스트 프로브(자동 삭제)' `
        -PlainPassword $plainPassword

    try {
        Start-ScheduledTask -TaskName $probeName -TaskPath $TaskPath
        $deadline = (Get-Date).AddMinutes(3)
        do {
            Start-Sleep -Seconds 2
            $state = (Get-ScheduledTask -TaskName $probeName -TaskPath $TaskPath).State
        } while ($state -eq 'Running' -and (Get-Date) -lt $deadline)

        if (-not (Test-Path -LiteralPath $probeOut)) {
            Write-Warn '프로브가 출력을 남기지 못했습니다. 작업이 아예 시작되지 못했을 수 있습니다.'
            Write-Warn '  → Get-WinEvent -LogName "Microsoft-Windows-TaskScheduler/Operational" -MaxEvents 30 으로 확인'
        } else {
            $raw = Get-Content -LiteralPath $probeOut -Raw
            try {
                $doc = $raw | ConvertFrom-Json
                $bad = @($doc.checks | Where-Object { -not $_.ok })
                if ($bad.Count -eq 0) {
                    Write-Good "프로브 통과: LogonType=$LogonType 에서 모든 진단이 정상입니다."
                } else {
                    Write-Warn "프로브 실패 항목 $($bad.Count) 개:"
                    $bad | ForEach-Object { Write-Warn ("  - [{0}] {1} : {2}" -f $_.key, $_.title, $_.detail) }
                    if ($bad.key -contains 'api_key') {
                        Write-Warn ''
                        Write-Warn '  ★ api_key 체크가 실패했다면 DPAPI 복호화가 이 로그온 타입에서 막힌 것입니다.'
                        Write-Warn '    다음 명령으로 암호 저장 방식으로 다시 등록하세요:'
                        Write-Warn "    .\scripts\install_tasks.ps1 -LogonType Password -Verify"
                    }
                }
            } catch {
                Write-Warn 'JSON 파싱 실패. 원문을 그대로 출력합니다:'
                Write-Host $raw
            }
        }
    } finally {
        Unregister-ScheduledTask -TaskName $probeName -TaskPath $TaskPath -Confirm:$false -ErrorAction SilentlyContinue
        Write-Step '프로브 작업 제거 완료'
    }
}

Write-Host ''
Write-Good '작업 등록이 끝났습니다. 다음 단계: §2.7 검증 명령을 실행하세요.'

2.4 scripts\uninstall_tasks.ps1 전문

#Requires -Version 5.1
<#
.SYNOPSIS
    DMF Crawler 작업 3종을 제거한다. 데이터·로그·리포트는 건드리지 않는다.
#>
[CmdletBinding()]
param(
    [string]$TaskPath = '\DMF_Crawler\',
    [switch]$RemoveFolder
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$names = @('DMF_Crawler_Daily', 'DMF_Crawler_Agent', 'DMF_Crawler_AgyUpdate', 'DMF_Crawler_Probe')

foreach ($n in $names) {
    $t = Get-ScheduledTask -TaskName $n -TaskPath $TaskPath -ErrorAction SilentlyContinue
    if ($t) {
        if ($t.State -eq 'Running') {
            Write-Host "[uninstall_tasks] 실행 중 → 중지: $n"
            Stop-ScheduledTask -TaskName $n -TaskPath $TaskPath
        }
        Unregister-ScheduledTask -TaskName $n -TaskPath $TaskPath -Confirm:$false
        Write-Host "[uninstall_tasks] 제거: $TaskPath$n"
    } else {
        Write-Host "[uninstall_tasks] 없음(건너뜀): $TaskPath$n"
    }
}

if ($RemoveFolder) {
    try {
        $svc = New-Object -ComObject 'Schedule.Service'
        $svc.Connect()
        $root = $svc.GetFolder('\')
        $root.DeleteFolder($TaskPath.Trim('\'), 0)
        Write-Host "[uninstall_tasks] 폴더 제거: $TaskPath"
    } catch {
        Write-Host "[uninstall_tasks] 폴더 제거 실패(무해): $($_.Exception.Message)"
    }
}

Write-Host '[uninstall_tasks] 완료. data\ logs\ reports\ backup\ state\ 는 그대로 남아 있습니다.'

2.5 실행 방법

# 관리자 권한 PowerShell 에서
cd D:\workspace\DMF_Crawler
powershell -ExecutionPolicy Bypass -File .\scripts\install_tasks.ps1 -Verify

ExecutionPolicy 를 영구히 바꾸지 않는다. 호출 시점의 -ExecutionPolicy Bypass 로 충분하다.

2.6 왜 액션이 powershell.exe 가 아니라 python.exe 인가

이유 설명
종료 코드 보존 중간에 powershell.exe 를 끼우면 스크립트가 $LASTEXITCODE 를 명시적으로 exit 하지 않는 한 종료 코드가 뭉개진다. RestartCount 는 종료 코드에 반응하므로 치명적이다.
콘솔 창 S4U 세션에는 데스크톱이 없어 python.exe 라도 창이 뜨지 않는다. 반대로 Interactive 로 도는 Agent 는 pythonw.exe 를 써야 검은 창이 깜빡이지 않는다.
레이어 하나 제거 인코딩(코드페이지 949 vs UTF-8), 실행 정책, 프로필 로딩 같은 PowerShell 고유 변수를 제거한다.

AGY_CLI_DISABLE_AUTO_UPDATE 는 작업에 설정하지 않는다. 작업 스케줄러 액션은 환경변수를 직접 넣을 수 없다. 이 변수는 agy/client.pysubprocess 를 띄울 때 자식 환경에 주입한다 — 배치가 아니라 코드의 책임이다.

2.7 등록 후 검증 명령

# (1) 작업 3종이 Ready 상태로 보이는가
Get-ScheduledTask -TaskPath '\DMF_Crawler\' |
    Format-Table TaskName, State -AutoSize

# (2) 다음 실행 시각이 내일 06:00 근처인가
Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' |
    Get-ScheduledTaskInfo |
    Format-List TaskName, NextRunTime, LastRunTime, LastTaskResult, NumberOfMissedRuns

# (3) 보안 컨텍스트가 의도대로인가 (SYSTEM 이면 즉시 실패로 간주하라)
(Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily').Principal |
    Format-List UserId, LogonType, RunLevel

# (4) 배터리·캐치업·절전 해제 4종이 제대로 뒤집혔는가
(Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily').Settings |
    Format-List DisallowStartIfOnBatteries, StopIfGoingOnBatteries,
                StartWhenAvailable, WakeToRun, RunOnlyIfNetworkAvailable,
                ExecutionTimeLimit, MultipleInstances, Priority

# (5) 재시작 정책
(Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily').Settings |
    Format-List RestartCount, RestartInterval

# (6) 트리거 2개(Daily + Boot)가 다 붙었는가. BootTrigger 의 Delay 가 PT5M 인가
(Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily').Triggers |
    Format-List CimClass, Enabled, StartBoundary, RandomDelay, Delay

# (7) 절전 해제 타이머로 등록됐는가 (WakeToRun 의 실제 효과 확인)
powercfg /waketimers

# (8) XML 전문을 눈으로 확인
Export-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'

# (9) 고전 도구로도 교차 확인
schtasks /Query /TN "\DMF_Crawler\DMF_Crawler_Daily" /V /FO LIST

자동 검증 스니펫 — 하나라도 어긋나면 붉게 출력한다. 설치 직후 그대로 붙여넣어라.

$t   = Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'
$s   = $t.Settings
$p   = $t.Principal
$exp = [ordered]@{
    'DisallowStartIfOnBatteries = False' = ($s.DisallowStartIfOnBatteries -eq $false)
    'StopIfGoingOnBatteries     = False' = ($s.StopIfGoingOnBatteries -eq $false)
    'StartWhenAvailable         = True'  = ($s.StartWhenAvailable -eq $true)
    'WakeToRun                  = True'  = ($s.WakeToRun -eq $true)
    'MultipleInstances    = IgnoreNew'   = ($s.MultipleInstances -eq 'IgnoreNew')
    'RestartCount               = 3'     = ($s.RestartCount -eq 3)
    'RestartInterval         = PT10M'    = ($s.RestartInterval -eq 'PT10M')
    'ExecutionTimeLimit      = PT30M'    = ($s.ExecutionTimeLimit -eq 'PT30M')
    'RunLevel            = Highest'      = ($p.RunLevel -eq 'Highest')
    'UserId != SYSTEM'                   = ($p.UserId -notmatch 'SYSTEM|LOCALSERVICE|NETWORKSERVICE')
    '트리거 2개(Daily + Boot)'            = ($t.Triggers.Count -eq 2)
}
$fail = 0
foreach ($k in $exp.Keys) {
    if ($exp[$k]) { Write-Host ("  OK   {0}" -f $k) -ForegroundColor Green }
    else          { Write-Host ("  FAIL {0}" -f $k) -ForegroundColor Red; $fail++ }
}
if ($fail -gt 0) { Write-Host "`n$fail 개 항목이 어긋났습니다. install_tasks.ps1 을 다시 실행하세요." -ForegroundColor Red }
else             { Write-Host "`n전 항목 통과." -ForegroundColor Green }

2.8 ★ S4U vs Password — 설치 시점에 반드시 실측할 단 하나의 분기

사실 관계부터 정확히. Microsoft 문서의 TASK_LOGON_S4U 설명 원문:

"Use an existing interactive token to run a task. The user must log on using a service for user (S4U) logon. When an S4U logon is used, no password is stored by the system and there is no access to either the network or encrypted files."

여기서 "no access to the network" 는 네트워크 자원에 사용자 자격증명으로 인증하는 것(UNC 공유, 매핑 드라이브, Kerberos 위임)을 말한다. 공개 HTTPS 엔드포인트로의 아웃바운드 요청은 막히지 않는다. 우리 크롤러는 https://apis.data.go.kr/... 하나만 호출하므로 이 제약에 걸리지 않는다.

진짜 위험은 "encrypted files" 쪽이다. S4U 로그온에는 사용자 암호가 개입하지 않으므로 DPAPI 사용자 마스터 키를 풀지 못해 CryptUnprotectData 가 실패할 수 있다. 우리는 공공데이터포털 API 키를 secrets_dpapi.py 로 DPAPI 암호화해 저장한다 → 정면 충돌 가능성이 있다.

S4U Password
암호 저장 안 함 Task Scheduler 자격증명 저장소에 저장
로그오프 상태 실행
아웃바운드 HTTPS
UNC·매핑 드라이브
DPAPI 사용자 범위 복호화 ⚠️ 실패할 수 있음 — 실측 필요
사용자 암호 변경 시 영향 없음 작업이 깨짐 → 재등록 필요
필요 권한 해당 계정에 Logon as Batch 동일

판정 절차(추측 금지):

# 1) S4U 로 등록하면서 프로브까지 수행
.\scripts\install_tasks.ps1 -LogonType S4U -Verify

# 2) 출력의 api_key 체크를 본다.
#    "OK"  → S4U 그대로 간다. 끝.
#    "FAIL: DPAPI ..." → 3) 으로.

# 3) 암호 저장 방식으로 재등록
.\scripts\install_tasks.ps1 -LogonType Password -Verify

Password 로 갔다면 반드시 기록할 것: 이 PC 는 Windows 로그인 암호를 바꾸는 순간 06:00 배치가 죽는다. 암호 변경 후 install_tasks.ps1 -LogonType Password 재실행이 필수 절차다. docs/ops/02-failure-alerting.md 의 복구 안내와 온보딩 GUI 체크 ⑨ 에 이 문구가 들어가야 한다.

Logon as Batch 권한: "Tasks registered with the TASK_LOGON_PASSWORD or TASK_LOGON_S4U flag will only launch if the specified user has the Logon as Batch privilege enabled. Administrators and Backup Operators group users have this privilege enabled by default." 개인 PC 의 관리자 계정이면 기본으로 있다. 없으면 secpol.msc → 로컬 정책 → 사용자 권한 할당 → 일괄 작업으로 로그온에 계정을 추가한다.


3. 동등한 schtasks XML 전문

작업 스케줄러 XML 은 형상관리 가능한 정본이다. scripts\tasks\ 아래에 두고 Git 에 올린다. <UserId> 와 경로만 PC 에 맞게 치환한다.

인코딩 주의: schtasks /Create /XMLUTF-16 LE 파일을 기대한다. PowerShell 에서 저장할 때 Out-File -Encoding Unicode 를 쓰거나, 인코딩 문제를 피하려면 Register-ScheduledTask -Xml (Get-Content -Raw -Encoding UTF8 ...) 를 쓴다.

3.1 scripts\tasks\DMF_Crawler_Daily.xml

<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
  <RegistrationInfo>
    <Date>2026-09-02T00:00:00</Date>
    <Author>DMF Crawler</Author>
    <Description>DMF 일일 수집·비교·리포트 배치. 매일 06:00 + 부팅 후 5분. UI 를 띄우지 않는다.</Description>
    <URI>\DMF_Crawler\DMF_Crawler_Daily</URI>
  </RegistrationInfo>
  <Triggers>
    <CalendarTrigger>
      <StartBoundary>2026-09-02T06:00:00</StartBoundary>
      <Enabled>true</Enabled>
      <RandomDelay>PT4M</RandomDelay>
      <ScheduleByDay>
        <DaysInterval>1</DaysInterval>
      </ScheduleByDay>
    </CalendarTrigger>
    <BootTrigger>
      <Enabled>true</Enabled>
      <Delay>PT5M</Delay>
    </BootTrigger>
  </Triggers>
  <Principals>
    <Principal id="Author">
      <UserId>DESKTOP-XXXXXXX\encep</UserId>
      <LogonType>S4U</LogonType>
      <RunLevel>HighestAvailable</RunLevel>
    </Principal>
  </Principals>
  <Settings>
    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
    <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
    <AllowHardTerminate>true</AllowHardTerminate>
    <StartWhenAvailable>true</StartWhenAvailable>
    <RunOnlyIfNetworkAvailable>true</RunOnlyIfNetworkAvailable>
    <IdleSettings>
      <StopOnIdleEnd>false</StopOnIdleEnd>
      <RestartOnIdle>false</RestartOnIdle>
    </IdleSettings>
    <AllowStartOnDemand>true</AllowStartOnDemand>
    <Enabled>true</Enabled>
    <Hidden>false</Hidden>
    <RunOnlyIfIdle>false</RunOnlyIfIdle>
    <DisallowStartOnRemoteAppSession>false</DisallowStartOnRemoteAppSession>
    <UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine>
    <WakeToRun>true</WakeToRun>
    <ExecutionTimeLimit>PT30M</ExecutionTimeLimit>
    <Priority>5</Priority>
    <RestartOnFailure>
      <Interval>PT10M</Interval>
      <Count>3</Count>
    </RestartOnFailure>
  </Settings>
  <Actions Context="Author">
    <Exec>
      <Command>D:\workspace\DMF_Crawler\.venv\Scripts\python.exe</Command>
      <Arguments>-m dmf_crawler run --trigger scheduled</Arguments>
      <WorkingDirectory>D:\workspace\DMF_Crawler</WorkingDirectory>
    </Exec>
  </Actions>
</Task>

XSD 상 유효성 근거 3가지 (이걸 모르면 "왜 임포트가 거부되는지" 를 못 찾는다):

  • <RestartOnFailure><Interval>PT1M 이상 P31D 이하로 제한된다. PT10M 은 유효.
  • <RestartOnFailure><Count>unsignedByte 이고 최소 1. 3 은 유효.
  • <ExecutionTimeLimit>PT0S 를 주면 무제한이 된다. 값을 아예 생략하면 기본 3일이다. 우리는 폭주 방지를 위해 PT30M 을 명시한다.

3.2 scripts\tasks\DMF_Crawler_Agent.xml

<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
  <RegistrationInfo>
    <Date>2026-09-02T00:00:00</Date>
    <Author>DMF Crawler</Author>
    <Description>DMF 알림 에이전트. 로그온 시 + 15분마다 heartbeat 를 점검하고 밀린 알림을 표시한다.</Description>
    <URI>\DMF_Crawler\DMF_Crawler_Agent</URI>
  </RegistrationInfo>
  <Triggers>
    <LogonTrigger>
      <Enabled>true</Enabled>
      <UserId>DESKTOP-XXXXXXX\encep</UserId>
      <Repetition>
        <Interval>PT15M</Interval>
        <StopAtDurationEnd>false</StopAtDurationEnd>
      </Repetition>
    </LogonTrigger>
    <TimeTrigger>
      <StartBoundary>2026-09-02T00:05:00</StartBoundary>
      <Enabled>true</Enabled>
      <Repetition>
        <Interval>PT15M</Interval>
        <StopAtDurationEnd>false</StopAtDurationEnd>
      </Repetition>
    </TimeTrigger>
  </Triggers>
  <Principals>
    <Principal id="Author">
      <UserId>DESKTOP-XXXXXXX\encep</UserId>
      <LogonType>InteractiveToken</LogonType>
      <RunLevel>LeastPrivilege</RunLevel>
    </Principal>
  </Principals>
  <Settings>
    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
    <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
    <AllowHardTerminate>true</AllowHardTerminate>
    <StartWhenAvailable>false</StartWhenAvailable>
    <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
    <IdleSettings>
      <StopOnIdleEnd>false</StopOnIdleEnd>
      <RestartOnIdle>false</RestartOnIdle>
    </IdleSettings>
    <AllowStartOnDemand>true</AllowStartOnDemand>
    <Enabled>true</Enabled>
    <Hidden>false</Hidden>
    <RunOnlyIfIdle>false</RunOnlyIfIdle>
    <DisallowStartOnRemoteAppSession>false</DisallowStartOnRemoteAppSession>
    <UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine>
    <WakeToRun>false</WakeToRun>
    <ExecutionTimeLimit>PT10M</ExecutionTimeLimit>
    <Priority>7</Priority>
  </Settings>
  <Actions Context="Author">
    <Exec>
      <Command>D:\workspace\DMF_Crawler\.venv\Scripts\pythonw.exe</Command>
      <Arguments>-m dmf_crawler notify-pump --once</Arguments>
      <WorkingDirectory>D:\workspace\DMF_Crawler</WorkingDirectory>
    </Exec>
  </Actions>
</Task>

<Repetition> 에서 <Duration>생략하면 무기한 반복이다. <StopAtDurationEnd>false</StopAtDurationEnd> 와 함께 쓴다. WakeToRunfalse — 알리미가 새벽 3시에 PC 를 깨우면 안 된다.

3.3 scripts\tasks\DMF_Crawler_AgyUpdate.xml

<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
  <RegistrationInfo>
    <Date>2026-09-02T00:00:00</Date>
    <Author>DMF Crawler</Author>
    <Description>agy CLI 주간 업데이트. 배치 시간대를 피해 일요일 14:00 에 돈다.</Description>
    <URI>\DMF_Crawler\DMF_Crawler_AgyUpdate</URI>
  </RegistrationInfo>
  <Triggers>
    <CalendarTrigger>
      <StartBoundary>2026-09-06T14:00:00</StartBoundary>
      <Enabled>true</Enabled>
      <RandomDelay>PT10M</RandomDelay>
      <ScheduleByWeek>
        <DaysOfWeek>
          <Sunday />
        </DaysOfWeek>
        <WeeksInterval>1</WeeksInterval>
      </ScheduleByWeek>
    </CalendarTrigger>
  </Triggers>
  <Principals>
    <Principal id="Author">
      <UserId>DESKTOP-XXXXXXX\encep</UserId>
      <LogonType>S4U</LogonType>
      <RunLevel>LeastPrivilege</RunLevel>
    </Principal>
  </Principals>
  <Settings>
    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
    <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
    <AllowHardTerminate>true</AllowHardTerminate>
    <StartWhenAvailable>true</StartWhenAvailable>
    <RunOnlyIfNetworkAvailable>true</RunOnlyIfNetworkAvailable>
    <IdleSettings>
      <StopOnIdleEnd>false</StopOnIdleEnd>
      <RestartOnIdle>false</RestartOnIdle>
    </IdleSettings>
    <AllowStartOnDemand>true</AllowStartOnDemand>
    <Enabled>true</Enabled>
    <Hidden>false</Hidden>
    <RunOnlyIfIdle>false</RunOnlyIfIdle>
    <DisallowStartOnRemoteAppSession>false</DisallowStartOnRemoteAppSession>
    <UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine>
    <WakeToRun>false</WakeToRun>
    <ExecutionTimeLimit>PT30M</ExecutionTimeLimit>
    <Priority>7</Priority>
  </Settings>
  <Actions Context="Author">
    <Exec>
      <Command>C:\Users\encep\AppData\Local\agy\bin\agy.exe</Command>
      <Arguments>update</Arguments>
      <WorkingDirectory>D:\workspace\DMF_Crawler</WorkingDirectory>
    </Exec>
  </Actions>
</Task>

%LOCALAPPDATA% 같은 환경변수는 <Command> 에서 전개되지 않는다. 절대 경로를 써야 한다.

3.4 XML 임포트 · 익스포트 명령

# ── 임포트 (schtasks, UTF-16 파일 전제) ──────────────────────────────
schtasks /Create /TN "\DMF_Crawler\DMF_Crawler_Daily" `
         /XML "D:\workspace\DMF_Crawler\scripts\tasks\DMF_Crawler_Daily.xml" /F

# S4U(암호 저장 안 함)로 계정을 지정해 임포트
schtasks /Create /TN "\DMF_Crawler\DMF_Crawler_Daily" `
         /XML "...\DMF_Crawler_Daily.xml" /RU "DESKTOP-XXXXXXX\encep" /F

# 암호 저장 방식으로 임포트 (실행 시 암호를 물어본다)
schtasks /Create /TN "\DMF_Crawler\DMF_Crawler_Daily" `
         /XML "...\DMF_Crawler_Daily.xml" /RU "DESKTOP-XXXXXXX\encep" /RP * /F

# ── 임포트 (PowerShell, 인코딩 걱정 없음) ────────────────────────────
$xml = Get-Content -Raw -Encoding UTF8 `
       'D:\workspace\DMF_Crawler\scripts\tasks\DMF_Crawler_Daily.xml'
Register-ScheduledTask -TaskName 'DMF_Crawler_Daily' -TaskPath '\DMF_Crawler\' `
    -Xml $xml -User 'DESKTOP-XXXXXXX\encep' -Force

# ── 익스포트 (현재 PC 의 실제 상태를 정본으로 되돌려 받기) ────────────
New-Item -ItemType Directory -Force -Path 'D:\workspace\DMF_Crawler\scripts\tasks' | Out-Null
foreach ($n in 'DMF_Crawler_Daily','DMF_Crawler_Agent','DMF_Crawler_AgyUpdate') {
    $t = Get-ScheduledTask -TaskName $n -TaskPath '\DMF_Crawler\' -ErrorAction SilentlyContinue
    if ($t) {
        Export-ScheduledTask -TaskName $n -TaskPath '\DMF_Crawler\' |
            Out-File -Encoding Unicode "D:\workspace\DMF_Crawler\scripts\tasks\$n.xml"
        Write-Host "exported: $n"
    }
}

4. 워치독과 heartbeat

4.1 heartbeat 파일 규약

항목
경로 D:\workspace\DMF_Crawler\state\heartbeat.json
인코딩 UTF-8 (BOM 없음), LF
쓰는 주체 pipeline.pyfinalize 스테이지 (성공 · 부분성공일 때만)
읽는 주체 watchdog.py(에이전트 안), checks.py 체크 ⑫, 운영자의 check_heartbeat.ps1
쓰기 방식 임시 파일 → os.replace 원자 교체. SQLite 잠금을 요구하지 않는다
갱신 시점 파이프라인 종료 직전. status ∈ {SUCCESS, PARTIAL} 일 때만 updated_at 을 현재 시각으로 밀어 올린다
갱신하지 않는 경우 FAILED, BLOCKED, ExecutionTimeLimit 강제 종료, 프로세스 크래시, 작업이 아예 시작되지 못함

★ 설계상 가장 중요한 한 줄: 실패했을 때 heartbeat 를 갱신하면 dead-man switch 가 설계상 무력화된다. "돌긴 돌았다"를 기록하고 싶은 유혹을 버려라. 실행 시도 자체는 runs 테이블과 events.jsonl 이 이미 남긴다.

4.2 state\heartbeat.json 스키마와 예시

{
  "schema": 1,
  "updated_at": "2026-09-02T06:04:37+09:00",
  "run_id": "20260902_060012",
  "run_date": "2026-09-02",
  "trigger": "scheduled",
  "status": "SUCCESS",
  "exit_code": 0,
  "started_at": "2026-09-02T06:00:12+09:00",
  "duration_seconds": 265,
  "records_total": 12874,
  "events": { "new": 3, "changed": 1, "withdrawn": 0 },
  "integrity": { "passed": true, "blocked_gate": null },
  "report_path": "D:\\workspace\\DMF_Crawler\\reports\\DMF_리포트_2026-09-02.xlsx",
  "log_dir": "D:\\workspace\\DMF_Crawler\\logs\\run_20260902_060012",
  "agy": { "used": true, "status": "OK", "tokens": 31245 },
  "consecutive_failures": 0,
  "host": "DESKTOP-XXXXXXX",
  "user": "encep",
  "version": "0.1.0"
}
필드 타입 의미
schema int 파일 포맷 버전. 읽는 쪽은 모르는 버전이면 WARN 후 무시
updated_at ISO8601 (오프셋 포함) 워치독 판정의 유일한 기준
run_id str YYYYMMDD_HHMMSS (KST). 로그 디렉터리 이름과 1:1
run_date YYYY-MM-DD 실행 대상 일자. idempotency 가드가 쓰는 키
trigger scheduled | startup | manual 어느 트리거로 돌았나
status SUCCESS | PARTIAL PARTIAL = 리포트는 나왔지만 무결성 게이트 차단 또는 AI 실패
exit_code int 0 / 1 / 2 / 130
duration_seconds int 사람이 "느려졌다"를 감지하는 지표
records_total int 오늘 수집 총 건수
events object 신규/변경/취하 건수
integrity.blocked_gate str | null 차단한 게이트 이름(있으면 PARTIAL 사유)
report_path str 알림에서 "리포트 열기" 버튼이 쓴다
log_dir str 알림에서 "로그 폴더 열기" 버튼이 쓴다
agy.status OK | SKIPPED | AUTH | QUOTA | ERROR AI 계층 결과
consecutive_failures int 마지막 성공 이후 누적 실패 횟수. notify.consecutive_failure_critical(3) 승격 판정에 쓴다

4.3 heartbeat 기록기 — src\dmf_crawler\watchdog.py 의 쓰기 부분

"""heartbeat 기록과 신선도 판정.

이 모듈은 SQLite 를 열지 않는다. 알림 에이전트가 배치와 락 경쟁을 하지
않고 상태를 읽을 수 있어야 하기 때문이다(아키텍처 §2 state/ 설명).
"""

from __future__ import annotations

import json
import os
import socket
import tempfile
from dataclasses import dataclass
from datetime import datetime, time, timedelta
from pathlib import Path
from typing import Any, Literal
from zoneinfo import ZoneInfo

HEARTBEAT_SCHEMA = 1

Status = Literal["SUCCESS", "PARTIAL"]


def write_heartbeat(path: Path, payload: dict[str, Any]) -> None:
    """heartbeat.json 을 원자적으로 교체한다.

    성공·부분성공일 때만 호출한다. 실패 시 호출하면 dead-man switch 가
    무력화된다.
    """
    payload = {"schema": HEARTBEAT_SCHEMA, **payload}
    payload.setdefault("host", socket.gethostname())
    payload.setdefault("user", os.environ.get("USERNAME", ""))

    path.parent.mkdir(parents=True, exist_ok=True)
    fd, tmp_name = tempfile.mkstemp(
        dir=str(path.parent), prefix=".heartbeat-", suffix=".tmp"
    )
    tmp = Path(tmp_name)
    try:
        with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as fh:
            json.dump(payload, fh, ensure_ascii=False, indent=2)
            fh.write("\n")
            fh.flush()
            os.fsync(fh.fileno())
        os.replace(tmp, path)
    except BaseException:
        tmp.unlink(missing_ok=True)
        raise

4.4 워치독 판정 로직 — 같은 파일의 읽기·판정 부분

나이브한 구현이 반드시 틀리는 지점: "heartbeat 가 120분보다 오래됐으면 경보" 라고 쓰면, 새벽 3시에는 어제 06:00 heartbeat 가 21시간 묵어 있으므로 매일 밤 오탐이 뜬다. 올바른 기준은 "가장 최근에 지나간 예정 실행 시각(expected_last_run) 이후에 갱신됐는가" 이다.

@dataclass(frozen=True, slots=True)
class Heartbeat:
    updated_at: datetime
    run_id: str
    run_date: str
    status: str
    exit_code: int
    report_path: str
    log_dir: str
    consecutive_failures: int
    raw: dict[str, Any]

    @classmethod
    def load(cls, path: Path, tz: ZoneInfo) -> "Heartbeat | None":
        """읽지 못하면 None. 예외를 밖으로 던지지 않는다 —
        진단기가 죽어서 진단이 안 되는 일이 없어야 한다."""
        try:
            doc = json.loads(path.read_text(encoding="utf-8"))
        except (OSError, ValueError):
            return None
        try:
            updated = datetime.fromisoformat(str(doc["updated_at"]))
        except (KeyError, ValueError):
            return None
        if updated.tzinfo is None:
            updated = updated.replace(tzinfo=tz)
        return cls(
            updated_at=updated.astimezone(tz),
            run_id=str(doc.get("run_id", "")),
            run_date=str(doc.get("run_date", "")),
            status=str(doc.get("status", "")),
            exit_code=int(doc.get("exit_code", -1)),
            report_path=str(doc.get("report_path", "")),
            log_dir=str(doc.get("log_dir", "")),
            consecutive_failures=int(doc.get("consecutive_failures", 0)),
            raw=doc,
        )


@dataclass(frozen=True, slots=True)
class Verdict:
    stale: bool
    code: str          # OK | NEVER_RAN | STALE | GRACE | DEGRADED
    expected_at: datetime | None
    age_minutes: int | None
    detail: str


def expected_last_run(now: datetime, daily_time: time) -> datetime:
    """now 기준으로 '가장 최근에 지나간 예정 실행 시각'."""
    today = now.replace(
        hour=daily_time.hour, minute=daily_time.minute, second=0, microsecond=0
    )
    return today if now >= today else today - timedelta(days=1)


def judge(
    hb: Heartbeat | None,
    *,
    now: datetime,
    daily_time: time,
    stale_minutes: int,
) -> Verdict:
    """워치독 판정.

    stale_minutes 는 '예정 시각 이후 이만큼 지나도 갱신이 없으면 경보'라는
    유예 시간이다(config: notify.watchdog_stale_minutes, 기본 120).
    지터 4분 + 재시작 3회 × 10분 = 최악 34분 을 충분히 덮는다.
    """
    expected = expected_last_run(now, daily_time)
    deadline = expected + timedelta(minutes=stale_minutes)

    if hb is None:
        if now < deadline:
            return Verdict(False, "GRACE", expected, None,
                           "아직 한 번도 실행되지 않았지만 유예 시간 안입니다.")
        return Verdict(True, "NEVER_RAN", expected, None,
                       "성공 기록이 한 번도 없습니다. 최초 설치가 끝나지 않았을 수 있습니다.")

    age = int((now - hb.updated_at).total_seconds() // 60)

    if hb.updated_at >= expected:
        code = "DEGRADED" if hb.status == "PARTIAL" else "OK"
        detail = (
            f"마지막 성공 {hb.updated_at:%Y-%m-%d %H:%M} "
            f"(run_id={hb.run_id}, status={hb.status})"
        )
        return Verdict(False, code, expected, age, detail)

    if now < deadline:
        return Verdict(False, "GRACE", expected, age,
                       f"{expected:%H:%M} 실행이 아직 진행 중이거나 재시도 중일 수 있습니다.")

    return Verdict(
        True, "STALE", expected, age,
        f"{expected:%Y-%m-%d %H:%M} 예정 실행의 성공 기록이 없습니다. "
        f"마지막 성공은 {hb.updated_at:%Y-%m-%d %H:%M} ({age}분 전)입니다.",
    )

4.5 판정 → 알림 연결

notify/pump.pyjudge() 결과를 받아 다음 표대로 행동한다. 알림 문구 4요소(무엇/왜/어떻게/다음 행동)는 alerts.raise_alert()계약으로 강제한다 — 하나라도 비면 ValueError.

code 등급 표시 방식 알림 코드 다음 행동 버튼
OK 표시 없음
GRACE 표시 없음
DEGRADED WARN 자동소멸 토스트 RUN_PARTIAL [리포트 열기] [로그 폴더 열기]
STALE CRITICAL 강제 모달 (gui.launch(mode="recover", focus_key="last_run")) WATCHDOG_STALE [지금 실행] [작업 상태 확인] [로그 폴더 열기]
NEVER_RAN CRITICAL 강제 모달 (mode="setup") WATCHDOG_NEVER_RAN [설치 마법사 열기]

중복 억제: dedup_key = code + run_date, 쿨다운 notify.cooldown_minutes(기본 240분). 즉 STALE 상태가 지속돼도 하루에 최대 몇 번만 창이 뜬다.

4.6 운영자용 즉석 점검 스크립트 — scripts\check_heartbeat.ps1

Python 환경이 깨졌을 때도 상태를 볼 수 있어야 한다. 읽기 전용이며 아무것도 등록하지 않는다.

#Requires -Version 5.1
<#
.SYNOPSIS
    state\heartbeat.json 을 읽어 06:00 배치의 최근 상태를 판정한다. 읽기 전용.
.OUTPUTS
    종료 코드 0 = 정상, 1 = 열화(PARTIAL), 2 = 경보(STALE / 파일 없음)
#>
[CmdletBinding()]
param(
    [string]$ProjectRoot  = (Split-Path -Parent $PSScriptRoot),
    [string]$DailyTime    = '06:00',
    [int]$StaleMinutes    = 120
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$hbPath = Join-Path $ProjectRoot 'state\heartbeat.json'
$now    = Get-Date

if (-not (Test-Path -LiteralPath $hbPath)) {
    Write-Host "[heartbeat] 파일이 없습니다: $hbPath" -ForegroundColor Red
    Write-Host '  → 성공 실행이 한 번도 없습니다. 다음을 실행하세요:' -ForegroundColor Red
    Write-Host '    .\.venv\Scripts\python.exe -m dmf_crawler doctor'
    exit 2
}

$hb  = Get-Content -LiteralPath $hbPath -Raw -Encoding UTF8 | ConvertFrom-Json
$upd = [datetime]::Parse($hb.updated_at)

$parts    = $DailyTime.Split(':')
$todayRun = $now.Date.AddHours([int]$parts[0]).AddMinutes([int]$parts[1])
$expected = if ($now -ge $todayRun) { $todayRun } else { $todayRun.AddDays(-1) }
$deadline = $expected.AddMinutes($StaleMinutes)
$ageMin   = [int]($now - $upd).TotalMinutes

Write-Host ''
Write-Host '  DMF Crawler heartbeat' -ForegroundColor Cyan
Write-Host '  ---------------------------------------------------------------'
Write-Host ("  마지막 성공   : {0:yyyy-MM-dd HH:mm:ss}  ({1}분 전)" -f $upd, $ageMin)
Write-Host ("  run_id        : {0}" -f $hb.run_id)
Write-Host ("  상태          : {0}  (exit={1})" -f $hb.status, $hb.exit_code)
Write-Host ("  수집 건수     : {0:N0}" -f $hb.records_total)
Write-Host ("  변경          : 신규 {0} / 변경 {1} / 취하 {2}" -f $hb.events.new, $hb.events.changed, $hb.events.withdrawn)
Write-Host ("  소요          : {0}초" -f $hb.duration_seconds)
Write-Host ("  AI            : {0}  (토큰 {1:N0})" -f $hb.agy.status, $hb.agy.tokens)
Write-Host ("  리포트        : {0}" -f $hb.report_path)
Write-Host ("  로그          : {0}" -f $hb.log_dir)
Write-Host ("  연속 실패     : {0}" -f $hb.consecutive_failures)
Write-Host ("  기준 예정시각 : {0:yyyy-MM-dd HH:mm}  (유예 {1}분 → {2:HH:mm})" -f $expected, $StaleMinutes, $deadline)
Write-Host '  ---------------------------------------------------------------'

if ($upd -ge $expected) {
    if ($hb.status -eq 'PARTIAL') {
        Write-Host '  판정: DEGRADED — 리포트는 나왔지만 일부 단계가 실패했습니다.' -ForegroundColor Yellow
        Write-Host '  → 로그 폴더의 pipeline.log 에서 WARN 을 확인하세요.'
        exit 1
    }
    Write-Host '  판정: OK — 최근 예정 실행이 성공했습니다.' -ForegroundColor Green
    exit 0
}

if ($now -lt $deadline) {
    Write-Host '  판정: GRACE — 아직 유예 시간 안입니다(실행 중이거나 재시도 중).' -ForegroundColor Yellow
    exit 0
}

Write-Host '  판정: STALE — 예정 실행의 성공 기록이 없습니다!' -ForegroundColor Red
Write-Host ''
Write-Host '  복구 순서:' -ForegroundColor Red
Write-Host '   1) Get-ScheduledTask -TaskPath "\DMF_Crawler\" | Get-ScheduledTaskInfo'
Write-Host '   2) .\.venv\Scripts\python.exe -m dmf_crawler doctor'
Write-Host '   3) .\.venv\Scripts\python.exe -m dmf_crawler run --trigger manual'
exit 2

5. 재부팅 · 전원 · 시각 시나리오 전체

5.1 시나리오 표 — "이 상황에서 06:00 배치는 어떻게 되는가"

# 상황 06:00 에 실행되는가 무엇이 구해주는가 사람이 할 일
1 PC 켜져 있고 로그온 06:00±4분 Daily 트리거 없음
2 PC 켜져 있고 로그오프 S4U/Password 로그온 타입 없음. 알림만 다음 로그온까지 지연
3 PC 켜져 있고 화면 잠금 잠금은 세션 종료가 아니다 없음
4 절전(S3/모던 대기) 깨워서 실행 WakeToRun + 전원 관리의 절전 해제 타이머 허용 §5.4 의 powercfg 1회 설정
5 최대 절전(S4) △ 하드웨어 의존 WakeToRun 은 S4 에서도 시도하지만 보장 없음 §5.4 로 최대 절전 자체를 끄는 것이 확실
6 완전히 꺼짐 06:00 에는 못 돎 켜지면 StartWhenAvailable 이 즉시 캐치업 + 부팅 트리거(+5분) 없음
7 06:00 직전 재부팅 중 그 순간엔 못 돎 StartWhenAvailable 캐치업 없음
8 Windows Update 재시작이 06:00 에 걸림 활성 시간 05:0023:00 으로 예방 + StartWhenAvailable §5.6 1회 설정
9 BitLocker PIN 입력 대기 부팅이 멈춰 있음 없음 — OS 가 아직 안 떴다 §5.9 판단
10 네트워크가 아직 안 올라옴(부팅 직후) BootTrigger Delay PT5M + RunOnlyIfNetworkAvailable + 코드의 재시도 4회 없음
11 06:00 실행 중 배터리로 전환 계속 실행 StopIfGoingOnBatteries=false 없음
12 배터리로만 구동 중 DisallowStartIfOnBatteries=false 없음
13 실행이 30분 초과 강제 종료 RestartCount 3 × PT10M 재시도 + 스테이지 체크포인트 재개 반복되면 source.page_size 조정
14 06:00 실행 성공 후 07:00 재부팅 재실행 안 함 idempotency 가드(오늘 SUCCESS → 종료 0) 없음
15 여러 날 꺼져 있다가 켜짐 당일분 1회만 StartWhenAvailable놓친 실행을 한 번만 몰아 실행한다 과거분은 backfill

5.2 부팅 후 네트워크 대기

부팅 트리거가 뜨는 시점에 네트워크 스택이 준비돼 있다는 보장이 없다. 3중으로 막는다.

  1. <BootTrigger><Delay>PT5M</Delay> — 부팅 후 5분 대기. New-ScheduledTaskTrigger -AtStartup 에는 -Delay 파라미터가 없어서 CIM 인스턴스의 Delay 속성을 직접 채운다(§2.3 코드 참조).
  2. RunOnlyIfNetworkAvailable=true — 네트워크가 없으면 아예 시작하지 않는다. 이때 이벤트 ID 112 (JobNoStartWithoutNetwork) 가 기록된다.
  3. 코드의 재시도source.max_attempts=4, backoff_base_seconds=5.0, Retry-After 절대 우선. DNS 가 잠깐 안 되는 정도는 여기서 흡수된다.

config.tomlschedule.startup_delay_minutes 를 늘리면 1번이 함께 늘어난다. 무선 랜만 쓰는 PC 에서 5분이 부족하면 10분으로 올린다.

5.3 놓친 작업 실행(캐치업)의 정확한 의미

StartWhenAvailable=true 의 공식 정의는 "Specifies that the Task Scheduler can start the task at any time after its scheduled time has passed." 실무적으로 알아야 할 것:

  • 여러 번 놓쳐도 몰아서 여러 번 돌지 않는다. 3일 꺼져 있다가 켜면 1회 실행된다. 과거 3일치 데이터가 필요하면 backfill 을 쓴다.
  • 캐치업 실행은 즉시가 아니라 최대 10분 이내에 트리거된다(스케줄러 내부 동작). 이벤트 ID 114 (MissedTaskLaunched) 로 확인할 수 있다.
  • 캐치업과 부팅 트리거가 겹쳐 두 번 뜰 수 있다. MultipleInstances=IgnoreNew(동시) + idempotency 가드(순차)가 둘 다 흡수한다. 이벤트 ID 322 (NewInstanceIgnored) 가 보이면 정상 동작이다.

5.4 절전 · 최대 절전 (powercfg)

관리자 권한 명령 프롬프트/PowerShell 에서 1회 실행.

# ── (0) 현재 상태 확인 ─────────────────────────────────────────────
powercfg /a                      # 이 PC 가 지원하는 절전 상태 (S0 모던 대기 여부 확인)
powercfg /waketimers             # 현재 등록된 절전 해제 타이머
powercfg /devicequery wake_armed # 깨울 수 있는 장치
powercfg /lastwake               # 마지막에 무엇이 깨웠는가

# ── (1) 절전 해제 타이머 '사용'으로 (WakeToRun 이 실제로 동작하려면 필수) ──
#      SUB_SLEEP      = 238c9fa8-0aad-41ed-83f4-97be242c8f20
#      절전 해제 타이머 허용 = bd3b718a-0680-4d9d-8ab2-e1d2b4ac806d
powercfg /setacvalueindex SCHEME_CURRENT 238c9fa8-0aad-41ed-83f4-97be242c8f20 bd3b718a-0680-4d9d-8ab2-e1d2b4ac806d 1
powercfg /setdcvalueindex SCHEME_CURRENT 238c9fa8-0aad-41ed-83f4-97be242c8f20 bd3b718a-0680-4d9d-8ab2-e1d2b4ac806d 1
powercfg /setactive SCHEME_CURRENT

# ── (2) 데스크톱 PC 권장: AC 전원에서는 아예 안 잔다 ───────────────
powercfg /change standby-timeout-ac 0     # 0 = 사용 안 함
powercfg /change hibernate-timeout-ac 0
powercfg /change monitor-timeout-ac 15    # 화면만 끈다 (전기·수명)
powercfg /change disk-timeout-ac 0

# ── (3) 노트북: 배터리에서도 06:00 을 지키고 싶다면 ────────────────
powercfg /change standby-timeout-dc 0
powercfg /change hibernate-timeout-dc 0
#   → 배터리 소모가 커진다. 그래도 실행이 우선이면 이렇게 한다.

# ── (4) 적용 확인 ─────────────────────────────────────────────────
powercfg /query SCHEME_CURRENT SUB_SLEEP
powercfg /waketimers

powercfg /waketimers 결과 읽는 법: 작업을 등록하고 WakeToRun=true 라면 다음 06:00 을 가리키는 항목이 하나 보여야 한다. 아무것도 안 보이면 (1) 단계를 안 했거나, 이 PC 가 모던 대기(S0) 라서 표기가 다를 수 있다. powercfg /a 의 출력에 대기 (S0 짧은 지연 사용 가능) 이 있으면 모던 대기 기기다.

모던 대기(S0) 주의: S0 기기에서는 "절전" 이 사실상 저전력 유지 상태라 예약 작업이 대체로 잘 돈다. 다만 네트워크가 오프로드 상태일 수 있어 첫 요청이 실패할 수 있다 — 코드의 재시도 4회가 흡수한다.

5.5 Fast Startup(빠른 시작)과 AtStartup 트리거

핵심 사실: 종료(Shutdown)를 눌러도 Windows 는 실제로 완전히 끄지 않는다. 커널 세션을 hiberfil.sys 에 저장하는 하이브리드 종료를 하고, 다음 켜기는 최대 절전 복귀다. 그래서 "시스템 시작 시" 트리거가 뜨지 않는다.

  • 재시작(Restart)에는 Fast Startup 이 적용되지 않는다. 재시작은 진짜 부팅이므로 AtStartup 이 뜬다.
  • Fast Startup 은 Windows 기본값으로 켜져 있고, Microsoft 는 끄는 것을 권장하지 않는다.

우리의 방침:

  1. Fast Startup 을 끄지 않는다. 부팅 트리거를 안전망으로 삼지 않기 때문이다. 진짜 안전망은 StartWhenAvailable 이고, 그것은 Fast Startup 과 무관하게 동작한다.
  2. 부팅 트리거는 "재시작 후 빠른 복귀" 용 보조 장치로만 취급한다.
  3. 그래도 끄고 싶다면(예: 이 PC 가 무인 서버 용도):
# 최대 절전 파일을 없애면 Fast Startup 도 함께 꺼진다
powercfg /h off

# 확인 — HiberbootEnabled 가 0 이면 Fast Startup 꺼짐
Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Power' |
    Select-Object HiberbootEnabled

# 되돌리기
powercfg /h on

powercfg /h off최대 절전 기능 자체를 없앤다. 노트북에서 최대 절전을 쓰고 있다면 부작용을 감수해야 한다. 그 경우 HiberbootEnabled 만 0 으로 두는 방법도 있으나, 최대 절전과 Fast Startup 의 상호작용이 기기마다 달라 ⚠️ 실측을 권한다.

5.6 Windows Update 재시작과 06:00 충돌 회피

Windows Update 는 설치 후 활성 시간(Active hours) 밖에서 자동 재시작한다. 기본 활성 시간은 08:0017:00 이므로 06:00 이 재시작 창에 정통으로 들어간다.

  • 활성 시간 최대 범위는 18시간(Windows 10 1607/Server 2016 은 12시간).
  • 05:00 시작 → 23:00 종료 = 18시간. 06:00 을 보호하면서 최대 범위에 딱 맞는다.
# ── 관리자 PowerShell ──────────────────────────────────────────────
# (A) 정책 경로 (권장 · 사용자가 UI 에서 못 바꾸게 고정)
$policy = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate'
New-Item -Path $policy -Force | Out-Null
Set-ItemProperty -Path $policy -Name 'SetActiveHours'   -Value 1  -Type DWord
Set-ItemProperty -Path $policy -Name 'ActiveHoursStart' -Value 5  -Type DWord
Set-ItemProperty -Path $policy -Name 'ActiveHoursEnd'   -Value 23 -Type DWord

# (B) 사용자 설정 경로 (UI 의 '활성 시간'과 같은 값)
$ux = 'HKLM:\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings'
New-Item -Path $ux -Force | Out-Null
Set-ItemProperty -Path $ux -Name 'ActiveHoursStart' -Value 5  -Type DWord
Set-ItemProperty -Path $ux -Name 'ActiveHoursEnd'   -Value 23 -Type DWord

# ── 확인 ──────────────────────────────────────────────────────────
Get-ItemProperty -Path $policy | Select-Object SetActiveHours, ActiveHoursStart, ActiveHoursEnd
Get-ItemProperty -Path $ux     | Select-Object ActiveHoursStart, ActiveHoursEnd

GUI 경로: 설정 → Windows Update → 고급 옵션 → 활성 시간.

완벽하지는 않다. 마감 기한(deadline)을 넘긴 강제 재시작은 활성 시간을 무시할 수 있다. 그래서 StartWhenAvailable 이 여전히 필요하다. 재시작으로 06:00 을 놓치면 부팅 후 5분(부팅 트리거) 또는 캐치업으로 자동 회복된다.

5.7 시간대 · DST

tzutil /g          # 반드시 "Korea Standard Time" 이어야 한다
w32tm /query /status
w32tm /resync      # 시각이 틀어졌으면
  • 한국은 1988년 이후 서머타임을 시행하지 않는다. KST = UTC+9 고정. DST 전환 시 작업이 1시간 일찍/늦게 도는 문제는 이 PC 에서 발생하지 않는다.
  • 트리거 속성의 "표준 시간대에 맞춰 동기화(Synchronize across time zones)" 를 켜지 마라. 켜면 StartBoundary 가 UTC 로 해석되어(...Z 접미사) 로컬 06:00 의 의미가 흔들린다. 우리 XML 의 <StartBoundary>2026-09-02T06:00:00</StartBoundary> 에는 의도적으로 Z 나 오프셋이 없다 — 로컬 시각이라는 뜻이다.
  • 코드 쪽 일자 판정은 general.timezone = "Asia/Seoul" 로 못박혀 있어 OS 시간대가 바뀌어도 run_date 는 흔들리지 않는다.
  • 노트북을 해외에 들고 나가 OS 시간대를 바꾸면 배치는 그 지역의 06:00 에 돈다. run_date 는 KST 기준이므로 하루에 두 번 돌거나 건너뛸 수 있다 — idempotency 가드가 중복은 막지만, 장기 해외 체류라면 시간대를 바꾸지 않는 편이 낫다.

5.8 자동 로그온이 필요한가 — 결론: 배치는 불필요, 알림은 조건부

대상 자동 로그온 필요? 이유
DMF_Crawler_Daily(배치) 불필요 S4U/Password 로그온 타입은 사용자가 로그오프 상태여도 실행된다
DMF_Crawler_AgyUpdate 불필요 동일
DMF_Crawler_Agent(알림·워치독) 필요할 수도 Interactive 는 로그온한 세션에서만 돈다. 로그오프 상태면 알림이 지연된다

로그오프 상태에서 실패했을 때 무슨 일이 일어나는가 (아키텍처 실패 경로 [F]):

  1. 배치가 alerts 테이블 + state\alerts.json + Windows 이벤트 로그에 기록한다.
  2. 화면에는 아무것도 안 뜬다.
  3. 다음 로그온 순간 DMF_Crawler_Agent 의 로그온 트리거가 즉시 발화해 밀린 알림을 전부 표시한다.

알림이 사라지는 게 아니라 늦어질 뿐이다. 대부분의 개인 PC 운용에서는 이걸로 충분하다.

그래도 즉시 알림이 필요하다면 — 세 가지 대안, 위험도 순:

대안 방법 위험
A. 로그온한 채 화면만 잠금 (권장) Win+L. 세션은 살아 있으므로 Agent 가 계속 돈다 없음. 물리 보안은 잠금 화면이 유지
B. 자동 로그온 + 즉시 잠금 netplwiz 또는 Sysinternals Autologon.exe 로 자동 로그온 설정 후, 시작 프로그램에 rundll32.exe user32.dll,LockWorkStation 등록 자동 로그온은 자격증명을 레지스트리에 남긴다(DefaultPassword). Autologon.exe 는 LSA 비밀에 저장해 그나마 낫다. BitLocker PIN 과 병용 시 무의미(§5.9)
C. 웹훅 알림 추가 배치가 실패 시 디스코드/슬랙 웹훅 호출 의존성·비밀 관리 증가. 현재 범위 밖(부록 참조)

기본 권고는 A 다. B 는 이 PC 에 다른 민감 데이터가 없고 물리적으로 안전한 위치일 때만.

5.9 BitLocker · PIN 의 영향

구성 무인 재부팅 후 06:00 배치 판정
BitLocker 미사용 정상 문제 없음
TPM 전용 (PIN 없음) 정상 — TPM 이 자동으로 볼륨을 해제하고 OS 가 부팅된다 이 프로젝트에 권장
TPM + PIN(사전 부팅 PIN) 부팅이 PIN 입력 화면에서 멈춘다. 사람이 PIN 을 넣기 전까지 OS 자체가 뜨지 않으므로 작업 스케줄러도 없다 자동화와 정면 충돌
TPM + 시작 키(USB) 동일 동일

Microsoft 문서: "Preboot authentication can make it more difficult to update unattended or remotely administered devices because a PIN must be entered when a device reboots or resumes from hibernation." / "The only supported silent configuration for BitLocker involves the TPM only."

PIN 을 유지해야 한다면: 재부팅 후 사람이 PIN 을 넣는 순간 OS 가 뜨고, 그때 StartWhenAvailable 이 놓친 06:00 을 즉시 실행한다. 즉 자동 복구는 되지만 시각은 밀린다. 이 지연을 허용할 수 없다면 TPM 전용으로 바꾸거나(보안 팀 승인 필요), 06:00 을 사람이 PC 앞에 있는 시각으로 옮겨야 한다.

현재 PC 의 상태 확인:

manage-bde -status C:
manage-bde -protectors -get C:     # TpmPin 이 보이면 PIN 구성이다

5.10 재부팅 내성 실증 절차 (설치 후 1회 반드시 수행)

# [테스트 1] 캐치업이 실제로 도는가 — 가장 중요한 테스트
#  1) 작업의 Daily 트리거 시각을 '지금부터 5분 뒤'로 임시 변경
#  2) PC 를 종료하고 10분 대기
#  3) PC 를 켜고 로그온하지 않은 채 5분 대기
#  4) 로그온해서 확인:
Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' | Get-ScheduledTaskInfo
Get-WinEvent -LogName 'Microsoft-Windows-TaskScheduler/Operational' -MaxEvents 50 |
    Where-Object { $_.Message -like '*DMF_Crawler_Daily*' } |
    Select-Object TimeCreated, Id, LevelDisplayName, Message | Format-List
#     → 이벤트 114(MissedTaskLaunched) 또는 118(BootTrigger) 이 보이면 성공
#  5) 트리거 시각을 06:00 으로 되돌린다 (install_tasks.ps1 재실행)

# [테스트 2] 재시작(Restart)에서 부팅 트리거가 뜨는가
Restart-Computer          # Fast Startup 이 적용되지 않는 경로
#  → 부팅 후 5분 뒤 이벤트 118 확인

# [테스트 3] 절전에서 깨어나 실행하는가
#  1) 트리거를 '지금부터 10분 뒤'로 변경
#  2) 즉시 절전 진입
rundll32.exe powrprof.dll,SetSuspendState 0,1,0
#  3) 15분 뒤 확인
powercfg /lastwake
#     → "절전 해제 원본: 타이머 - ... DMF_Crawler_Daily" 가 보이면 성공

# [테스트 4] 로그오프 상태에서 도는가 (S4U/Password 검증)
#  1) 로그오프
#  2) 트리거 시각 경과 후 다시 로그온
#  3) state\heartbeat.json 의 updated_at 이 갱신됐는지 확인
.\scripts\check_heartbeat.ps1

6. 중복 실행 방지

6.1 3중 방어와 각 층이 막는 것

구현 막는 것 못 막는 것
① 스케줄러 MultipleInstances=IgnoreNew 동시 실행(06:00 트리거와 부팅 트리거가 겹칠 때) 순차 재실행
② 파일 락 state\run.lock + msvcrt.locking 스케줄러 밖에서 시작된 동시 실행(수동 + 자동) 순차 재실행
③ 코드 가드 repo.last_success_run_on(오늘) 조회 순차 재실행(06:00 성공 후 07:00 재부팅 캐치업) 의도적 재실행(--force 로 우회)

③ 이 없으면 append-only 스키마가 오염된다. 같은 날 스냅샷이 두 벌 들어가면 다음 날 diff 의 기준선이 어느 쪽인지 모호해진다. 스케줄러 설정만 믿는 설계는 여기서 무너진다.

6.2 src\dmf_crawler\runlock.py 전문

"""state\\run.lock 배타 락. Windows 전용(msvcrt).

파일 끝 멀찍한 오프셋의 1바이트를 잠그고, 사람이 읽을 메타데이터는 파일
앞쪽(잠그지 않은 영역)에 고정 길이로 쓴다. 잠근 영역을 truncate 하면
Windows 에서 오류가 나기 때문이다.
"""

from __future__ import annotations

import contextlib
import datetime as _dt
import msvcrt
import os
import socket
from collections.abc import Iterator
from pathlib import Path

from .errors import DmfError

_LOCK_OFFSET = 1_000_000   # 이 위치의 1바이트를 잠근다
_META_SIZE = 256           # 파일 앞 256바이트에 메타데이터를 고정 길이로 기록


class LockBusy(DmfError):
    """다른 인스턴스가 이미 락을 쥐고 있다."""


def read_holder(lock_path: Path) -> str:
    """락 파일 앞부분의 메타데이터를 그대로 읽는다(진단용). 실패하면 빈 문자열."""
    try:
        with open(lock_path, "rb") as fh:
            return fh.read(_META_SIZE).decode("utf-8", "replace").rstrip("\x00 \n")
    except OSError:
        return ""


@contextlib.contextmanager
def exclusive(lock_path: Path, *, run_id: str = "", trigger: str = "") -> Iterator[None]:
    """배타 락을 잡는다. 이미 잡혀 있으면 LockBusy 를 던진다(대기하지 않는다).

    사용:
        try:
            with runlock.exclusive(paths.STATE / "run.lock", run_id=rid):
                ...
        except runlock.LockBusy:
            log.info("다른 인스턴스가 실행 중 — 종료 코드 0")
            return 0
    """
    lock_path.parent.mkdir(parents=True, exist_ok=True)

    # a+b: 없으면 만들고, 있으면 내용을 지우지 않는다.
    fh = open(lock_path, "a+b")
    try:
        # 파일이 오프셋보다 짧으면 잠글 바이트가 없다. 미리 늘려 둔다.
        fh.seek(0, os.SEEK_END)
        if fh.tell() <= _LOCK_OFFSET:
            fh.write(b"\x00" * (_LOCK_OFFSET + 1 - fh.tell()))
            fh.flush()

        fh.seek(_LOCK_OFFSET)
        try:
            msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
        except OSError as exc:
            holder = read_holder(lock_path)
            raise LockBusy(
                f"이미 실행 중입니다. lock={lock_path} holder=[{holder}]"
            ) from exc

        # 여기부터 락 보유 구간
        meta = (
            f"pid={os.getpid()} host={socket.gethostname()} "
            f"user={os.environ.get('USERNAME', '')} "
            f"run_id={run_id} trigger={trigger} "
            f"acquired={_dt.datetime.now().astimezone().isoformat(timespec='seconds')}"
        ).encode("utf-8")[: _META_SIZE - 1]
        fh.seek(0)
        fh.write(meta.ljust(_META_SIZE, b" ") + b"\n")
        fh.flush()
        os.fsync(fh.fileno())

        try:
            yield
        finally:
            fh.seek(_LOCK_OFFSET)
            with contextlib.suppress(OSError):
                msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
    finally:
        fh.close()

왜 뮤텍스(CreateMutex)가 아니라 파일 락인가

파일 락 (채택) 네임드 뮤텍스
세션 경계 파일이므로 세션·로그온 타입 무관 Global\ 접두사가 없으면 세션마다 별개. S4U 세션과 Interactive 세션이 서로를 못 본다
잔해 프로세스가 죽으면 OS 가 핸들을 닫아 락이 자동 해제 동일하나, 소유권 포기(abandoned) 처리를 코드가 다뤄야 함
진단성 누가 잡고 있는지 파일을 열어보면 안다 밖에서 볼 방법이 사실상 없다
의존성 stdlib msvcrt ctypes 로 Win32 직접 호출

세션 경계 문제 하나만으로 결정된다. 배치는 S4U 세션, 수동 실행은 Interactive 세션에서 뜨는데 뮤텍스는 Global\ 을 빼먹는 순간 조용히 무력화된다.

6.3 idempotency 가드의 정확한 규칙

run --trigger scheduled|startup 로 진입
  └─ repo.last_success_run_on(today, tz="Asia/Seoul") 조회
       ├─ status ∈ {SUCCESS, PARTIAL} 인 run 이 있다  → "SKIPPED" 기록 후 종료 코드 0
       └─ 없다                                        → 정상 진행

--force 가 붙으면 이 조회를 건너뛴다.
report-only / backfill / doctor 는 가드 대상이 아니다.
  • PARTIAL 도 "오늘은 이미 돌았다"로 친다. 리포트가 나왔기 때문이다. 다시 돌리고 싶으면 --force.
  • FAILED / BLOCKED 는 가드에 걸리지 않는다 → 재시작(RestartCount)이 정상적으로 재시도한다.
  • 스테이지 체크포인트(stage_status)와는 다른 층이다. 체크포인트는 같은 run_id 안에서 성공한 스테이지를 건너뛰고, 가드는 다른 run_id 의 재실행 자체를 막는다.

6.4 락이 걸려 있을 때 사람이 하는 일

# 1) 누가 잡고 있는지 본다
Get-Content -LiteralPath 'D:\workspace\DMF_Crawler\state\run.lock' -TotalCount 1

# 2) 그 PID 가 정말 살아 있는지 확인
Get-Process -Id <PID> -ErrorAction SilentlyContinue |
    Select-Object Id, ProcessName, StartTime, Path

# 3) 살아 있으면 기다린다(정상). ExecutionTimeLimit 30분 안에 끝난다.
# 4) 죽어 있는데 락이 남아 있다면 → 있을 수 없는 상황이다.
#    OS 가 핸들을 닫으면서 락도 풀리기 때문이다.
#    그래도 의심되면 파일을 지운다(실행 중이 아님을 확인한 뒤에만):
Remove-Item -LiteralPath 'D:\workspace\DMF_Crawler\state\run.lock' -Force

# 5) 작업 자체를 강제 종료해야 한다면
Stop-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'

7. 로그 규약

7.1 실행 ID

  • 형식: YYYYMMDD_HHMMSS (KST, 프로세스 시작 시각). 예: 20260902_060012
  • 생성: run 진입 직후, 로그 디렉터리를 만들기 전에 한 번.
  • 전파: runs.run_id(PK) · stage_status · snapshots · events · agy_calls · heartbeat.json · 로그 디렉터리 이름 · 알림 레코드에 동일한 값이 들어간다.
  • 지터로 06:00:00 이 아니라 06:04:12 에 시작해도 run_date2026-09-02 다. run_idrun_date 를 혼동하지 마라.

7.2 디렉터리와 파일

D:\workspace\DMF_Crawler\logs\
└── run_20260902_060012\
    ├── pipeline.log        사람이 읽는 전체 로그 (텍스트)
    ├── events.jsonl        기계가 읽는 구조화 이벤트 (1줄 1 JSON)
    ├── agy.stdout.json     agy JSON 봉투 원문 (감사용, 손대지 않은 원본)
    ├── agy.stderr.log      agy 진단 출력 (stdout 과 절대 섞지 않는다)
    └── agy_cli.log         agy --log-file 로 지정한 agy 내부 로그

실행 1회 = 디렉터리 1개. 조사의 시작점이 "logs 에서 가장 최근 디렉터리를 연다" 하나로 고정된다. 롤링 단일 파일은 여러 실행이 뒤섞여 "어느 줄이 어제 것인지" 를 매번 다시 따져야 한다.

agy 의 stdout 과 stderr 을 분리하는 것은 agy SSOT §5.3 규약이다. 섞으면 JSON 파싱이 오염된다.

7.3 pipeline.log 포맷

2026-09-02 06:00:12.431 +0900 | INFO     | run          | run_id=20260902_060012 trigger=scheduled 시작
2026-09-02 06:00:12.502 +0900 | INFO     | runlock      | 락 획득 state\run.lock
2026-09-02 06:00:12.610 +0900 | INFO     | preflight    | 스키마 버전 3, integrity_check ok, 여유 공간 214.6GB
2026-09-02 06:00:13.004 +0900 | INFO     | fetch        | totalCount=12874 pages=129 page_size=100
2026-09-02 06:01:47.882 +0900 | WARNING  | http         | 429 Retry-After=30 page=61 attempt=1 대기 30.0s
2026-09-02 06:02:31.115 +0900 | INFO     | fetch        | 완료 12874건 129페이지 138.1s 아카이브=data\raw\2026-09-02
2026-09-02 06:02:33.900 +0900 | INFO     | normalize    | 12874건 정규화, dmf_key 충돌 2건 결정론적 접미사 부여
2026-09-02 06:02:34.220 +0900 | INFO     | integrity    | 게이트 5/5 통과 (drop=0.001 null=0.000 dup=0.0002)
2026-09-02 06:02:34.905 +0900 | INFO     | diff         | new=3 changed=1 withdrawn=0 base_run=20260901_060008
2026-09-02 06:02:36.470 +0900 | INFO     | persist      | 스냅샷 12874행 · 이벤트 4행 커밋
2026-09-02 06:03:41.008 +0900 | INFO     | enrich       | agy exit=0 status=OK in=28914 out=1204 41.9s
2026-09-02 06:04:29.771 +0900 | INFO     | report       | 8시트 생성 → reports\DMF_리포트_2026-09-02.xlsx (원자 교체)
2026-09-02 06:04:35.310 +0900 | INFO     | backup       | VACUUM INTO backup\dmf_2026-09-02.sqlite3 (48.2MB), 보존 30개 유지
2026-09-02 06:04:37.002 +0900 | INFO     | finalize     | status=SUCCESS exit=0 265.6s heartbeat 갱신
컬럼 내용
1 로컬 시각 + UTC 오프셋(+0900). 오프셋을 빼먹으면 로그를 나중에 못 믿는다
2 레벨 (DEBUG/INFO/WARNING/ERROR) — logging.level 로 하한 조절
3 스테이지 또는 모듈 이름
4 메시지

마스킹: logging.mask_patterns(기본 ["serviceKey", "access_token"])에 걸리는 키의 값은 기록 직전에 *** 로 치환한다. API 키가 URL 쿼리에 들어가므로 요청 URL 로깅은 항상 마스킹을 통과해야 한다.

7.4 events.jsonl 스키마

한 줄에 JSON 객체 하나. 기계가 읽는다. 필드는 다음을 항상 포함한다.

필드 타입 설명
ts ISO8601+오프셋 이벤트 시각
run_id str 실행 ID
stage str run | preflight | fetch | … | finalize
event str 이벤트 이름(아래 표)
level str INFO | WARN | ERROR
data object 이벤트별 페이로드

주요 event 값과 data 필드:

event data 주요 키
run_started trigger, pid, version, python, run_date
stage_started / stage_finished stage, status, duration_ms
fetch_page page, http_status, bytes, elapsed_ms, attempt
http_retry page, reason, retry_after_s, sleep_s, attempt
fetch_summary total_count, pages, records, duration_ms, archive_dir
integrity_gate gate, passed, observed, threshold
diff_summary new, changed, withdrawn, base_run_id
agy_call exit_code, status, model, tokens_in, tokens_out, duration_ms, error_class
report_written path, sheets, bytes, fallback_name
alert_raised code, severity, dedup_key, suppressed
run_finished status, exit_code, duration_ms, records_total

예시 3줄:

{"ts":"2026-09-02T06:00:12.431+09:00","run_id":"20260902_060012","stage":"run","event":"run_started","level":"INFO","data":{"trigger":"scheduled","pid":18244,"version":"0.1.0","python":"3.12.6","run_date":"2026-09-02"}}
{"ts":"2026-09-02T06:01:47.882+09:00","run_id":"20260902_060012","stage":"fetch","event":"http_retry","level":"WARN","data":{"page":61,"reason":"429","retry_after_s":30,"sleep_s":30.0,"attempt":1}}
{"ts":"2026-09-02T06:04:37.002+09:00","run_id":"20260902_060012","stage":"finalize","event":"run_finished","level":"INFO","data":{"status":"SUCCESS","exit_code":0,"duration_ms":265571,"records_total":12874}}

필수 기록 필드 체크리스트 (요구 7항):

  • 시작 — run_started(트리거·PID·버전)
  • 종료 — run_finished(상태·종료 코드)
  • 건수 — fetch_summary.records, diff_summary.{new,changed,withdrawn}
  • 소요 — 각 stage_finished.duration_ms + run_finished.duration_ms
  • 토큰 사용량 — agy_call.{tokens_in,tokens_out}
  • 오류 — level:"ERROR" 이벤트 + alert_raised

7.5 로테이션·보존

대상 설정 키 기본 정리 시점
로그 디렉터리 logs\run_* logging.retain_days 90일 finalize 스테이지
API 원문 아카이브 data\raw\<날짜> source.archive_retain_days 180일 finalize 스테이지
리포트 reports\*.xlsx report.retain_days 365일 finalize 스테이지
DB 백업 backup\*.sqlite3 backup.keep_count 30개 backup 스테이지
Task Scheduler Operational 로그 wevtutil /maxsize 64MB OS 가 순환

파일 단위 롤링(RotatingFileHandler)을 쓰지 않는 이유: 실행 1회당 로그가 수십 KB 로 작고, 디렉터리 단위 보존이 훨씬 단순하다. 롤링은 "이 줄이 어느 실행 것인가" 를 매번 되묻게 만든다.

수동으로 지금 정리하려면:

$root = 'D:\workspace\DMF_Crawler'
$cut  = (Get-Date).AddDays(-90)
Get-ChildItem -LiteralPath "$root\logs" -Directory -Filter 'run_*' |
    Where-Object { $_.LastWriteTime -lt $cut } |
    Remove-Item -Recurse -Force -WhatIf     # 확인 후 -WhatIf 를 떼고 재실행

7.6 디스크 사용량 감각

항목 1일 90일 비고
로그 디렉터리 ~60KB ~5MB agy 봉투 포함
API 원문 아카이브 ~4MB ~360MB(180일) 페이지 129개 × ~30KB
리포트 xlsx ~1.5MB ~550MB(365일) 시트 8종
DB 증분 ~3MB ~270MB append-only 스냅샷
DB 백업 ~50MB ~1.5GB(30개) VACUUM INTO 압축본

backup.min_free_gb(기본 2.0) 미만이면 백업을 건너뛰고 WARN 을 남긴다. 여유가 10GB 미만으로 떨어지면 source.archive_retain_days 를 줄이는 것이 가장 효과가 크다.


8. 일상 운영 절차

8.1 정상 동작 확인 (아침 30초)

cd D:\workspace\DMF_Crawler

# (1) 가장 빠른 방법 — heartbeat 한 방
.\scripts\check_heartbeat.ps1

# (2) 오늘 리포트가 나왔는가
Get-ChildItem .\reports -Filter "DMF_리포트_$(Get-Date -Format 'yyyy-MM-dd').xlsx" |
    Format-List Name, Length, LastWriteTime

# (3) 스케줄러가 보는 마지막 결과 (0x0 이면 정상)
Get-ScheduledTask -TaskPath '\DMF_Crawler\' | Get-ScheduledTaskInfo |
    Select-Object TaskName,
                  LastRunTime,
                  @{ n='LastResult'; e={ '0x{0:X}' -f $_.LastTaskResult } },
                  NextRunTime |
    Format-Table -AutoSize

# (4) 전체 진단 (느리지만 확실)
.\.venv\Scripts\python.exe -m dmf_crawler doctor

# (5) 오늘 로그를 눈으로
$last = Get-ChildItem .\logs -Directory -Filter 'run_*' | Sort-Object Name -Descending | Select-Object -First 1
Get-Content "$($last.FullName)\pipeline.log" -Tail 40

8.2 수동 재실행

cd D:\workspace\DMF_Crawler

# (A) 스케줄러를 통해 실행 — 실제 배치와 100% 같은 보안 컨텍스트로 돈다.
#     "스케줄에서만 실패하는" 문제를 재현하는 유일한 방법이다.
Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'
#     완료 대기
while ((Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily').State -eq 'Running') {
    Start-Sleep -Seconds 5; Write-Host '.' -NoNewline
}
Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' | Get-ScheduledTaskInfo

# (B) 콘솔에서 직접 실행 — 로그를 눈으로 보며 디버깅할 때
.\.venv\Scripts\python.exe -m dmf_crawler run --trigger manual

# (C) 오늘 이미 성공했는데 그래도 다시 돌리고 싶다
.\.venv\Scripts\python.exe -m dmf_crawler run --trigger manual --force

# (D) 네트워크를 건드리지 않고 흐름만 점검
.\.venv\Scripts\python.exe -m dmf_crawler run --trigger manual --dry-run

# (E) AI 단계를 건너뛰고 실행 (토큰 절약 / agy 장애 시)
.\.venv\Scripts\python.exe -m dmf_crawler run --trigger manual --skip-agy

# (F) 수집·비교는 그대로 두고 리포트 파일만 다시 만든다
#     (엑셀로 열어둬서 저장이 실패했을 때가 대표적)
.\.venv\Scripts\python.exe -m dmf_crawler report-only
.\.venv\Scripts\python.exe -m dmf_crawler report-only --date 2026-09-01

(A) 와 (B) 의 차이를 항상 의식하라. (B) 는 당신의 Interactive 세션에서 돈다. DPAPI·프로필·PATH 가 전부 다르다. "손으로는 되는데 06:00 에는 안 된다" 의 원인은 거의 항상 여기다.

8.3 특정 날짜 백필

# 원문 아카이브(data\raw\<날짜>)가 남아 있는 구간만 대상이다.
# 파서를 고친 뒤 과거를 다시 해석할 때 쓴다.

# (1) 아카이브가 어느 날짜까지 있는지 확인
Get-ChildItem .\data\raw -Directory | Select-Object -ExpandProperty Name

# (2) 원문 재파싱 + diff 재계산 + 리포트 재생성
.\.venv\Scripts\python.exe -m dmf_crawler backfill `
    --from 2026-08-25 --to 2026-08-31 --reparse --rediff --rereport

# (3) 리포트만 다시 만들기(파서·diff 는 그대로)
.\.venv\Scripts\python.exe -m dmf_crawler backfill `
    --from 2026-08-25 --to 2026-08-31 --rereport

# (4) 백필 전 반드시 백업
.\.venv\Scripts\python.exe -m dmf_crawler backup --now

백필로 할 수 없는 것: 아카이브가 없는 날짜는 복원할 수 없다. 공식 API 는 "특정 과거 시점의 스냅샷" 을 제공하지 않기 때문이다. 그래서 data\raw\ 보존이 중요하다(source.archive_retain_days=180).

8.4 일시 중지 · 재개

# 배치만 멈춘다 (알림 에이전트는 계속 돈다 → 곧 STALE 경보가 뜬다)
Disable-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'

# 장기 휴지(휴가 등)라면 알림도 함께 멈춰 오탐을 막는다
Disable-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Agent'

# 상태 확인 — State 가 Disabled 로 보인다
Get-ScheduledTask -TaskPath '\DMF_Crawler\' | Format-Table TaskName, State -AutoSize

# 재개
Enable-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'
Enable-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Agent'

# 재개 직후 확인: NextRunTime 이 다음 06:00 을 가리키는가
Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' |
    Get-ScheduledTaskInfo | Select-Object NextRunTime

중지 중에 지나간 06:00 은 재개해도 캐치업하지 않는다. StartWhenAvailable 은 "작업이 활성인데 못 돈 경우" 를 다룬다. 비활성 기간의 데이터가 필요하면 backfill 을 쓰거나, 그날 데이터는 없는 것으로 확정된다(공식 API 에 과거 스냅샷이 없으므로).

8.5 제거

# (1) 작업만 제거 — 데이터는 남는다
powershell -ExecutionPolicy Bypass -File .\scripts\uninstall_tasks.ps1

# (2) 작업 + 폴더까지 제거
powershell -ExecutionPolicy Bypass -File .\scripts\uninstall_tasks.ps1 -RemoveFolder

# (3) 바로가기 제거
Remove-Item "$env:USERPROFILE\Desktop\DMF 설정.lnk"  -ErrorAction SilentlyContinue
Remove-Item "$env:USERPROFILE\Desktop\지금 실행.lnk"  -ErrorAction SilentlyContinue

# (4) 저장된 API 키(DPAPI) 제거
.\.venv\Scripts\python.exe -m dmf_crawler doctor --json    # 무엇이 남아있는지 먼저 확인
# GUI: "DMF 설정" → API 키 → 삭제

# (5) 완전 삭제 (되돌릴 수 없다. 백업을 먼저 다른 곳으로 옮겨라)
Remove-Item -LiteralPath 'D:\workspace\DMF_Crawler' -Recurse -Force

# (6) 이 프로젝트가 바꾼 시스템 설정 되돌리기 (선택)
powercfg /change standby-timeout-ac 30
#   활성 시간 정책 제거
Remove-ItemProperty -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' `
    -Name 'SetActiveHours','ActiveHoursStart','ActiveHoursEnd' -ErrorAction SilentlyContinue

agy 자체는 이 프로젝트 소유가 아니다. 다른 용도로도 쓴다면 지우지 마라. 지우려면 %LOCALAPPDATA%\agy~\.gemini\antigravity-cli 를 삭제한다. 토큰 파일이므로 삭제 = 로그아웃이다.


9. 점검 체크리스트

9.1 설치 직후 (1회, 전부 통과해야 운영 개시)

  • python --version 이 3.12 이상
  • .venv\Scripts\python.exe -m dmf_crawler version 이 패키지·의존성 3종·agy 버전을 출력
  • .venv\Scripts\python.exe -m dmf_crawler doctor12종 전부 통과(종료 코드 0)
  • Get-ScheduledTask -TaskPath '\DMF_Crawler\'3개Ready 로 보여줌
  • DMF_Crawler_DailyPrincipal.UserIdSYSTEM 이 아님, RunLevel=Highest
  • §2.7 자동 검증 스니펫이 전 항목 통과
  • §2.8 프로브(-Verify)에서 api_key 체크 통과 — 실패했다면 -LogonType Password 로 재등록했고 다시 통과
  • powercfg /waketimers 에 다음 06:00 항목이 보임
  • 활성 시간이 0523 으로 설정됨(§5.6 확인 명령)
  • tzutil /gKorea Standard Time
  • wevtutil get-log "Microsoft-Windows-TaskScheduler/Operational"enabled: true
  • Start-ScheduledTask스케줄러 경유 1회 실행 성공reports\ 에 오늘 xlsx 생성
  • state\heartbeat.json 이 생성되고 status=SUCCESS
  • .\scripts\check_heartbeat.ps1 이 종료 코드 0
  • backup\dmf_<날짜>.sqlite3 생성 (backup.dir다른 드라이브를 가리키면 더 좋다)
  • DMF 설정.lnk / 지금 실행.lnk 더블클릭 시 콘솔 창 없이 GUI 가 뜸
  • §5.10 테스트 1(캐치업)을 실제로 수행하고 이벤트 114 또는 118 확인
  • BitLocker 구성 확인 — TPM+PIN 이면 §5.9 의 지연을 이해관계자가 수용

9.2 매주 (5분, 월요일 권장)

  • .\scripts\check_heartbeat.ps1 — 판정 OK, consecutive_failures = 0
  • 최근 7일 리포트가 7개 있는가: Get-ChildItem .\reports -Filter '*.xlsx' | Sort-Object LastWriteTime -Descending | Select-Object -First 8
  • duration_seconds 추이 — 지난주 대비 2배 이상 늘었으면 원인 확인
  • Get-ScheduledTaskInfoLastTaskResult 가 7일 내내 0x0
  • agy 상태: 최근 7일 heartbeat.agy.statusAUTH 로 바뀐 적 없는가 (있으면 재로그인 필요)
  • 일일 토큰 사용량이 agy.daily_token_cap(300000) 대비 여유 있는가
  • 디스크 여유: Get-PSDrive D | Select-Object Used, Free
  • 백업 개수: (Get-ChildItem .\backup -Filter '*.sqlite3').Count 가 30 이하
  • DMF_Crawler_AgyUpdate 가 지난 일요일에 돌았고 agy --version 이 갱신됐는가
  • 로그에 신규 WARN 패턴이 있는가:
    Get-ChildItem .\logs -Directory -Filter 'run_*' |
        Sort-Object Name -Descending | Select-Object -First 7 |
        ForEach-Object { Select-String -Path "$($_.FullName)\pipeline.log" -Pattern 'WARNING|ERROR' } |
        Group-Object { ($_.Line -split '\|')[3].Trim() } |
        Sort-Object Count -Descending | Format-Table Count, Name -AutoSize
    

9.3 장애 시 (STALE 경보가 떴을 때 순서대로)

  • 1단계 — 무엇이 죽었나
    .\scripts\check_heartbeat.ps1
    Get-ScheduledTask -TaskPath '\DMF_Crawler\' | Get-ScheduledTaskInfo |
        Select-Object TaskName, LastRunTime, @{n='R';e={'0x{0:X}' -f $_.LastTaskResult}}, NextRunTime
    
  • 2단계 — 작업이 시작조차 못 했나, 시작했다가 실패했나
    Get-WinEvent -LogName 'Microsoft-Windows-TaskScheduler/Operational' -MaxEvents 100 |
        Where-Object { $_.Message -like '*DMF_Crawler*' } |
        Select-Object TimeCreated, Id, LevelDisplayName,
                      @{n='Msg';e={ ($_.Message -split "`n")[0] }} |
        Format-Table -AutoSize
    
    → 이벤트 ID 로 §11.2 표를 참조한다. 101/104/332시작 실패(보안·전원 문제), 102/201 이 있으면 시작은 했다(코드 문제).
  • 3단계 — 시작했다면 로그를 본다
    $last = Get-ChildItem .\logs -Directory -Filter 'run_*' | Sort-Object Name -Descending | Select-Object -First 1
    Get-Content "$($last.FullName)\pipeline.log" -Tail 60
    Get-Content "$($last.FullName)\events.jsonl" | Select-Object -Last 20
    
  • 4단계 — 전면 진단
    .\.venv\Scripts\python.exe -m dmf_crawler doctor
    
  • 5단계 — 스케줄러 경유로 재현
    Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'
    
  • 6단계 — 그래도 안 되면 컨텍스트 차이를 의심하고 §2.8 프로브를 다시 돌린다
    .\scripts\install_tasks.ps1 -Verify
    
  • 7단계 — 작업 정의가 손상됐다면 재등록
    .\scripts\install_tasks.ps1
    # 또는 GUI 의 "작업 다시 등록" 버튼 / .\.venv\Scripts\python.exe -m dmf_crawler doctor --fix-tasks
    
  • 8단계 — DB 가 의심되면
    .\.venv\Scripts\python.exe -m dmf_crawler db check
    .\.venv\Scripts\python.exe -m dmf_crawler db version
    # 손상 확인 시: backup\ 의 최신본을 data\dmf.sqlite3 로 복사 후 report-only
    

자동 재등록을 하지 않는 이유: 사용자가 의도적으로 작업을 껐을 수 있다. 진단은 자동, 복구는 사람의 클릭 한 번이 원칙이다(아키텍처 실패 시나리오 #29).


10. 완전한 설치 절차 (새 PC, 0부터)

소요 20~30분. 관리자 권한 PowerShell 을 기본으로 한다.

단계 0 — 전제 확인

# Windows 버전 (Windows 10 1809 이상 / Windows 11 권장)
[System.Environment]::OSVersion.Version
winver

# 아키텍처 (agy 는 windows_amd64)
$env:PROCESSOR_ARCHITECTURE      # AMD64 여야 한다

# 관리자 권한인가
([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole('Administrator')

# 이 계정이 배치를 돌릴 계정인가 (agy 토큰이 이 프로필에 저장된다)
whoami

준비물: ① 공공데이터포털 계정과 DMF 서비스 활용 신청 승인(활용 신청 직후 키가 즉시 유효하지 않을 수 있다) ② Google 계정(agy 로그인용) ③ 디스크 여유 10GB 이상.

단계 1 — Python 설치

# winget 이 있으면 (권장)
winget install --id Python.Python.3.12 --scope machine --silent `
    --override "/quiet InstallAllUsers=1 PrependPath=1 Include_test=0"

# 새 셸을 열고 확인
python --version        # Python 3.12.x
py -3.12 --version

수동 설치라면 python.org 에서 3.12 64-bit 를 받고 설치 시 "Add python.exe to PATH" 를 반드시 체크한다.

3.11 이상이 필요하다(tomllib). 3.12 를 권장한다.

단계 2 — 프로젝트 배치

# Git 으로 받는 경우
git clone <저장소 URL> D:\workspace\DMF_Crawler

# 압축본을 푸는 경우
Expand-Archive .\DMF_Crawler.zip -DestinationPath D:\

cd D:\workspace\DMF_Crawler
Get-ChildItem   # bootstrap.cmd, pyproject.toml, src\, scripts\, config\ 가 보여야 한다

경로 규칙: 공백·한글이 없는 짧은 경로를 쓴다. OneDrive 동기화 폴더(%USERPROFILE%\OneDrive\...) 아래에 두지 마라 — SQLite WAL 과 충돌하고, 리포트 파일이 동기화 중 잠긴다.

단계 3 — 부트스트랩 (venv + 의존성 + 바로가기)

:: 프로젝트 루트에서 더블클릭하거나
D:\workspace\DMF_Crawler\bootstrap.cmd

bootstrap.cmd 가 하는 일:

  1. python -m venv .venv
  2. .venv\Scripts\python.exe -m pip install --upgrade pip
  3. .venv\Scripts\python.exe -m pip install -e . → 런타임 의존성 3개(httpx, XlsxWriter, jsonschema) 설치
  4. scripts\make_shortcuts.ps1 호출 → 바탕화면에 DMF 설정.lnk / 지금 실행.lnk 생성
  5. 온보딩 GUI 기동 (pythonw -m dmf_crawler onboard --mode setup)

수동으로 같은 일을 하려면:

cd D:\workspace\DMF_Crawler
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install --upgrade pip
.\.venv\Scripts\python.exe -m pip install -e .
.\.venv\Scripts\python.exe -m dmf_crawler version

단계 4 — agy 부트스트랩

# 자동
powershell -ExecutionPolicy Bypass -File .\scripts\bootstrap_agy.ps1

# 스크립트가 하는 일:
#  1) %LOCALAPPDATA%\agy\bin\agy.exe 존재 확인
#  2) 없으면 공식 설치 스크립트 무인 실행
#  3) agy --version 출력

수동 설치:

irm https://antigravity.google/cli/install.ps1 | iex

# 확인
& "$env:LOCALAPPDATA\agy\bin\agy.exe" --version

로그인 — 반드시 배치를 돌릴 그 계정으로:

& "$env:LOCALAPPDATA\agy\bin\agy.exe" login
# 브라우저가 열린다. Google 계정으로 로그인.

# 토큰 파일이 생겼는지 확인 (이 파일이 배치 인증의 전부다)
Get-Item "$env:USERPROFILE\.gemini\antigravity-cli\antigravity-oauth-token" |
    Format-List FullName, Length, LastWriteTime

# 헤드리스 왕복 실측 (30초 이상 걸리는 게 정상 — 첫 호출 오버헤드)
& "$env:LOCALAPPDATA\agy\bin\agy.exe" -p "Reply with exactly: PONG" `
    --output-format json --print-timeout 90s
$LASTEXITCODE   # 0 이어야 한다

antigravity-oauth-token 은 평문 JSON 이다. 유출 = 계정 탈취다. 이 파일을 백업·공유·Git 에 올리지 마라. .gitignore*oauth-token* 이 들어 있는 이유다.

단계 5 — 설정과 API 키

# (1) 설정 파일 확인
Get-Content .\config\config.toml | Select-Object -First 20

# (2) PC 별 오버라이드가 필요하면 (백업을 다른 드라이브로 등)
Copy-Item .\config\config.local.toml.example .\config\config.local.toml
notepad .\config\config.local.toml
#   [backup]
#   dir = "E:/DMF_Backup"

# (3) API 키 등록 — GUI 가 DPAPI 로 암호화 저장한다
.\.venv\Scripts\pythonw.exe -m dmf_crawler onboard --mode setup
#   또는 바탕화면의 "DMF 설정" 더블클릭

# (4) 연락처 이메일도 넣는다 (User-Agent 에 들어간다. 정중한 접근 원칙)
#     config.toml 의 general.contact_email

API 키를 config.toml 에 직접 쓰지 마라. 그 파일은 Git 에 올라간다. 온보딩 GUI 를 통하면 DPAPI 사용자 범위로 암호화되어 저장된다.

단계 6 — 첫 실행 (스케줄러 없이)

# (1) 진단부터
.\.venv\Scripts\python.exe -m dmf_crawler doctor

# (2) DB 초기화
.\.venv\Scripts\python.exe -m dmf_crawler db migrate
.\.venv\Scripts\python.exe -m dmf_crawler db version

# (3) 실제 1회 실행 (첫 실행은 기준선 수립 — diff 는 비어 있는 게 정상)
.\.venv\Scripts\python.exe -m dmf_crawler run --trigger manual

# (4) 결과 확인
Get-ChildItem .\reports
Get-Content .\state\heartbeat.json

첫 실행에서 diff 가 0 인 것은 정상이다. 비교 대상(전일 스냅샷)이 없기 때문이다. 진짜 검증은 둘째 날에 이루어진다.

단계 7 — 시스템 설정 (전원 · 업데이트 · 시간대)

# 절전 해제 타이머 허용
powercfg /setacvalueindex SCHEME_CURRENT 238c9fa8-0aad-41ed-83f4-97be242c8f20 bd3b718a-0680-4d9d-8ab2-e1d2b4ac806d 1
powercfg /setdcvalueindex SCHEME_CURRENT 238c9fa8-0aad-41ed-83f4-97be242c8f20 bd3b718a-0680-4d9d-8ab2-e1d2b4ac806d 1
powercfg /setactive SCHEME_CURRENT

# AC 전원에서는 안 자게
powercfg /change standby-timeout-ac 0
powercfg /change hibernate-timeout-ac 0

# Windows Update 활성 시간 0523 (06:00 보호)
$ux = 'HKLM:\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings'
New-Item -Path $ux -Force | Out-Null
Set-ItemProperty -Path $ux -Name 'ActiveHoursStart' -Value 5  -Type DWord
Set-ItemProperty -Path $ux -Name 'ActiveHoursEnd'   -Value 23 -Type DWord

# 시간대 확인
tzutil /g       # Korea Standard Time
w32tm /resync

단계 8 — 작업 등록

cd D:\workspace\DMF_Crawler
powershell -ExecutionPolicy Bypass -File .\scripts\install_tasks.ps1 -Verify

프로브에서 api_key 가 실패하면:

powershell -ExecutionPolicy Bypass -File .\scripts\install_tasks.ps1 -LogonType Password -Verify

단계 9 — 등록 검증

§2.7 의 자동 검증 스니펫을 그대로 붙여넣어 전 항목 통과를 확인한다. 이어서:

# 스케줄러 경유 실행이 성공하는가 (이게 진짜 검증이다)
Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'
while ((Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily').State -eq 'Running') {
    Start-Sleep -Seconds 5
}
Get-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily' | Get-ScheduledTaskInfo
.\scripts\check_heartbeat.ps1

단계 10 — 알림 경로 검증

# 에이전트를 즉시 한 번 돌린다
Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Agent'

# 인위적으로 STALE 을 만들어 강제 모달이 실제로 뜨는지 본다
Copy-Item .\state\heartbeat.json .\state\heartbeat.json.bak
# heartbeat.json 의 updated_at 을 이틀 전으로 편집
notepad .\state\heartbeat.json
Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Agent'
#   → 복구 GUI 가 떠야 한다

# 원상복구
Move-Item .\state\heartbeat.json.bak .\state\heartbeat.json -Force

이 테스트를 건너뛰지 마라. 알림이 안 뜨는 시스템은 감시 없는 시스템과 같다.

단계 11 — 다음 날 아침 확인 (설치 완료 판정)

.\scripts\check_heartbeat.ps1
#   판정 OK + run_id 가 오늘 06:0x + status=SUCCESS
Get-ChildItem .\reports -Filter "DMF_리포트_$(Get-Date -Format 'yyyy-MM-dd').xlsx"

여기까지 통과해야 설치 완료다. 손으로 돌려본 것만으로는 완료가 아니다.

설치 요약 카드 (복사용)

# 관리자 PowerShell, 순서대로
winget install --id Python.Python.3.12 --scope machine --silent
git clone <URL> D:\workspace\DMF_Crawler ; cd D:\workspace\DMF_Crawler
.\bootstrap.cmd
powershell -ExecutionPolicy Bypass -File .\scripts\bootstrap_agy.ps1
& "$env:LOCALAPPDATA\agy\bin\agy.exe" login
.\.venv\Scripts\pythonw.exe -m dmf_crawler onboard --mode setup     # API 키 입력
.\.venv\Scripts\python.exe  -m dmf_crawler db migrate
.\.venv\Scripts\python.exe  -m dmf_crawler run --trigger manual
powercfg /change standby-timeout-ac 0
powershell -ExecutionPolicy Bypass -File .\scripts\install_tasks.ps1 -Verify
Start-ScheduledTask -TaskPath '\DMF_Crawler\' -TaskName 'DMF_Crawler_Daily'
.\scripts\check_heartbeat.ps1

11. 트러블슈팅 레퍼런스

11.1 LastTaskResult 값 읽는 법

Get-ScheduledTaskInfoLastTaskResult작업이 실행한 프로그램의 종료 코드 또는 스케줄러 자체의 HRESULT 다.

의미 우리 시스템에서의 해석
0x0 성공 정상 (SUCCESS / PARTIAL / SKIPPED)
0x1 프로그램이 1 로 종료 FAILED — 리포트 미생성. 재시도 가치 있음
0x2 프로그램이 2 로 종료 BLOCKED — 전제조건 미충족(API 키·설정·DB). 재시도해도 같다
0x82 (130) 사용자 중단 콘솔에서 Ctrl+C
0x41300 SCHED_S_TASK_READY — 다음 예정 시각에 실행 대기 정상
0x41301 SCHED_S_TASK_RUNNING — 현재 실행 중 정상 (조회 시점에 돌고 있음)
0x41302 SCHED_S_TASK_DISABLED — 비활성화됨 누가 껐다. Enable-ScheduledTask
0x41303 SCHED_S_TASK_HAS_NOT_RUN — 한 번도 안 돎 등록 직후의 정상 상태
0x41304 SCHED_S_TASK_NO_MORE_RUNS 트리거가 만료됐다. 재등록 필요
0x41305 SCHED_S_TASK_NOT_SCHEDULED — 예약에 필요한 속성 누락 트리거가 없거나 깨졌다. 재등록
0x41306 SCHED_S_TASK_TERMINATED — 마지막 실행이 종료됨 ExecutionTimeLimit 초과 또는 Stop-ScheduledTask
0x41307 SCHED_S_TASK_NO_VALID_TRIGGERS 트리거가 없거나 전부 비활성
0x41325 SCHED_S_TASK_QUEUED 큐에 대기 중
0x8004130F SCHED_E_ACCOUNT_INFORMATION_NOT_SET 자격증명 문제. 암호 변경 후 재등록 안 했거나 S4U 등록 실패
0x80041310 SCHED_E_ACCOUNT_NAME_NOT_FOUND -User 로 준 계정명이 틀렸다
0x8004131F SCHED_E_ALREADY_RUNNING 이미 실행 중 (IgnoreNew 정상 동작)
0x80041321 SCHED_E_INVALID_TASK_HASH — 작업 이미지 손상 작업 정의가 손상됐다. 재등록이 유일한 해법
0x800710E0 요청 거부 관리자 권한/RunLevel 문제

시스템·네트워크 오류 코드(예: 64)가 그대로 나올 수도 있다. net helpmsg 64 로 의미를 확인한다.

11.2 Microsoft-Windows-TaskScheduler/Operational 주요 이벤트 ID

ID 이름 의미 우리 시스템에서
100 JobStart 작업 인스턴스 시작 정상
101 JobStartFailed 작업 시작 실패 보안 컨텍스트·경로 문제
102 JobSuccess 작업 완료 정상
103 JobFailure 작업 실행 실패 종료 코드 비0
104 LogonFailure 사용자 로그온 실패 암호 변경 / S4U 실패 → §2.8
105 ImpersonationFailure 가장(impersonation) 실패 동일 계열
106 JobRegistered 작업 등록됨 install_tasks.ps1 실행 흔적
107 TimeTrigger 시간 트리거로 실행 06:00 정상 발화
108 EventTrigger 이벤트 트리거로 실행 미사용
110 Run 사용자를 위해 실행 시작 정상
111 JobTermination 시간 초과로 종료 ExecutionTimeLimit 30분 초과
112 JobNoStartWithoutNetwork 네트워크 없어 미시작 RunOnlyIfNetworkAvailable 동작
114 MissedTaskLaunched 놓친 작업을 실행 StartWhenAvailable 캐치업 성공
116 TaskRegisteredWithoutCredentials 자격증명 저장 실패 Password 등록 실패
118 BootTrigger 부팅 트리거로 실행 재시작 후 정상 발화
119 LogonTrigger 로그온 트리거로 실행 Agent 정상
126 FailedTaskRestart 실패한 작업 재시작 시도 RestartCount 동작 중
129 CreatedTaskProcess 새 프로세스로 실행 PID 확인용
140 / 141 / 142 TaskUpdated / TaskDeleted / TaskDisabled 사람이 작업을 바꿨다 작업이 사라진 원인 추적
145 TaskStartedOnComputerWakeup 절전 해제 후 실행 WakeToRun 성공 증거
200 / 201 ActionStart / ActionSuccess 액션 시작·완료 201ResultCode 가 종료 코드
202 ActionFailure 액션 실패 프로그램이 비0 종료
203 ActionLaunchFailure 액션 실행 실패 경로가 틀렸거나 실행 권한 없음
322 NewInstanceIgnored 이미 실행 중이라 무시 IgnoreNew 정상 동작
326 NoStartOnBatteries 배터리라서 미시작 설정이 안 뒤집혔다! §2.7 재확인
327 StoppingOnBatteries 배터리 전환으로 중단 동일
329 StoppingOnTimeout 시간 초과로 중단 111 과 함께 본다
332 NoStartUserNotLoggedOn 사용자 미로그온으로 미시작 Interactive 작업의 정상 동작
400 / 401 ScheduleServiceStart / StartFailed 스케줄러 서비스 상태 서비스 자체 문제

조회 명령:

# 우리 작업 관련만, 최근 100건
Get-WinEvent -LogName 'Microsoft-Windows-TaskScheduler/Operational' -MaxEvents 200 |
    Where-Object { $_.Message -like '*DMF_Crawler*' } |
    Select-Object TimeCreated, Id, LevelDisplayName,
                  @{ n='First'; e={ ($_.Message -split "`n")[0] } } |
    Format-Table -AutoSize

# 특정 ID 만 (시작 실패 계열)
Get-WinEvent -FilterHashtable @{
    LogName = 'Microsoft-Windows-TaskScheduler/Operational'
    Id      = 101, 103, 104, 111, 112, 202, 203, 326, 332
    StartTime = (Get-Date).AddDays(-7)
} | Format-List TimeCreated, Id, Message

11.3 증상 → 원인 → 조치 빠른 표

증상 가장 흔한 원인 조치
06:00 에 아무 일도 안 일어남, LastRunTime 이 어제 PC 가 꺼져 있었다 정상. 켜지면 캐치업. powercfg /waketimers 확인
이벤트 326 이 보임 DisallowStartIfOnBatteries 가 안 뒤집혔다 install_tasks.ps1 재실행
이벤트 104, LastTaskResult=0x8004130F 사용자 암호를 바꿨다 install_tasks.ps1 -LogonType Password 재실행
손으로는 되는데 스케줄러로는 종료 코드 2 DPAPI 가 S4U 에서 실패 §2.8 프로브 → -LogonType Password
agy 만 실패, 나머지 정상 OAuth 토큰 만료 agy login 재실행. 리포트는 계속 나온다(설계상 정상)
이벤트 111 + 329 30분 초과 source.page_size 하향 또는 execution_time_limit_minutes 상향
리포트가 _HHMMSS 붙은 이름으로 저장됨 Excel 로 파일을 열어뒀다 Excel 닫고 report-only
이벤트 322 가 매일 2건 06:00 트리거와 부팅 트리거가 겹침 정상. 방어가 작동 중
작업이 통째로 사라짐 이벤트 141 확인 → 누가 지웠다 install_tasks.ps1 재등록
NumberOfMissedRuns 가 계속 증가 작업이 실행 자체를 못 하고 있다 이벤트 101/104/332 확인
알림이 전혀 안 뜸 로그오프 상태였거나 Agent 가 비활성 Get-ScheduledTask ... Agent 상태 확인, §5.8
doctor 는 통과하는데 06:00 만 실패 컨텍스트 차이 반드시 Start-ScheduledTask 로 재현

부록. 미해결 / 실측 필요

  • S4U 로그온에서 DPAPI 사용자 범위 CryptUnprotectData 가 성공하는가. 이 런북 전체에서 가장 중요한 미검증 항목이다. Microsoft 문서는 "no access to ... encrypted files" 만 말할 뿐 DPAPI 마스터 키에 대해 명시하지 않는다. §2.8 프로브로 설치 시점에 반드시 실측하고 결과를 이 문서에 기록할 것.
  • New-ScheduledTaskTrigger -AtStartup 이 반환하는 CIM 인스턴스에 Delay 속성을 대입하는 방식이 Windows 11 26xxx 빌드에서 정상 반영되는지. 등록 후 Export-ScheduledTask<Delay>PT5M</Delay> 가 실제로 들어갔는지 눈으로 확인할 것.
  • -RepetitionDuration ([TimeSpan]::MaxValue) 가 이 빌드에서 예외 없이 통과하는지. 통과했다면 XML 에 <Duration> 이 생략되는지(=무기한) 확인. 폴백(3650일) 경로가 실제로 필요한지 판정.
  • 모던 대기(S0) 기기에서 WakeToRun 이 실제로 06:00 에 깨우는가. powercfg /a 로 이 PC 의 절전 상태 종류를 먼저 확정하고, §5.10 테스트 3 을 수행할 것.
  • powercfg /waketimers 출력에 DMF_Crawler_Daily 가 표시되는 정확한 문구. 표시되지 않는데 실제로는 깨우는 사례가 있으므로, 표시 유무만으로 판정하지 말 것.
  • Fast Startup 이 켜진 상태에서 "종료 → 켜기" 시 StartWhenAvailable 캐치업이 몇 분 안에 뜨는지. 이벤트 114 의 실제 지연 시간 측정.
  • Windows Update 마감 기한(deadline) 초과 시 활성 시간을 무시하고 0523 사이에 재시작하는 사례가 이 PC 에서 발생하는지. 발생 빈도에 따라 06:00 을 다른 시각으로 옮길지 판단.
  • agy update 의 정확한 종료 코드와, 업데이트 중 다른 agy 인스턴스가 있을 때의 동작(agy SSOT 부록 미해결 항목과 동일). DMF_Crawler_AgyUpdate 실패 시 알림을 띄울지 여부는 이 결과에 달려 있다.
  • DMF_Crawler_AgyUpdate 를 S4U 로 등록했을 때 agy update 가 OAuth 토큰 파일을 정상적으로 읽는가. 읽지 못한다면 이 작업만 Interactive 로 내려야 한다.
  • 배치가 실행되는 30분 사이에 사용자가 리포트 xlsx 를 열어두는 빈도. 폴백 파일명 발생률이 높으면 report.lock_retries 상향 또는 저장 시각 조정 검토.
  • 로그오프 상태를 며칠 유지하는 운영 형태가 실제로 발생하는가. 발생한다면 §5.8 대안 B(자동 로그온) 또는 웹훅 확장(현재 범위 밖)을 다음 마일스톤 후보로 승격.
  • state\run.lock_LOCK_OFFSET = 1_000_000 로 파일이 1MB 로 잡히는 것이 스파스 파일로 처리되는지(NTFS). 아니라면 오프셋을 4096 정도로 줄여도 되는지 확인.
  • msvcrt.locking 이 네트워크 드라이브·OneDrive 동기화 폴더에서 오동작하는 조건. 프로젝트 경로를 로컬 고정 디스크로 제한하는 것을 온보딩 체크에 추가할지 판단.
  • wevtutil set-log ... /maxsize:67108864 가 관리자 권한 없이도 성공하는지, 실패 시 무해한지. install_tasks.ps1 이 이미 관리자 전제이므로 실무상 문제는 없으나 문구를 확정할 것.
  • doctor --json 의 출력 스키마(checks[].key/title/ok/detail)가 install_tasks.ps1 -Verify 의 파싱과 일치하는지. checks.py 구현 시 이 계약을 깨지 말 것 — 깨지면 프로브가 조용히 무력화된다.